267 lines
6.4 KiB
JavaScript
267 lines
6.4 KiB
JavaScript
#!/usr/bin/env bun
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { createMcpHttpHeaders } from "./mcp-http-headers.mjs";
|
|
|
|
const CONFIG_PATH = process.env.EDGEEVER_CONFIG || join(homedir(), ".edgeever", "config.json");
|
|
const DEFAULT_URL = "http://127.0.0.1:8787";
|
|
const options = parseOptions(process.argv.slice(2));
|
|
|
|
const usage = `EdgeEver MCP stdio adapter
|
|
|
|
Usage:
|
|
EDGEEVER_URL=https://your.edgeever.host EDGEEVER_TOKEN=... bun scripts/edgeever-mcp-stdio.mjs
|
|
bun scripts/edgeever-mcp-stdio.mjs --profile prod
|
|
|
|
The adapter bridges local stdio MCP clients to the remote EdgeEver /mcp endpoint.
|
|
`;
|
|
|
|
if (options.help || options.h) {
|
|
process.stdout.write(usage);
|
|
process.exit(0);
|
|
}
|
|
|
|
let client;
|
|
|
|
try {
|
|
client = await createClient(options.profile);
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
}
|
|
|
|
let inputBuffer = Buffer.alloc(0);
|
|
let transportMode = null;
|
|
let negotiatedProtocolVersion = null;
|
|
|
|
process.stdin.on("data", (chunk) => {
|
|
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
|
drainInput();
|
|
});
|
|
|
|
process.stdin.on("error", (error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
});
|
|
|
|
process.stdin.resume();
|
|
|
|
function drainInput() {
|
|
while (inputBuffer.length > 0) {
|
|
const framed = readFramedMessage(inputBuffer);
|
|
|
|
if (framed) {
|
|
transportMode = "framed";
|
|
inputBuffer = inputBuffer.subarray(framed.consumedBytes);
|
|
void handleMessage(framed.payload);
|
|
continue;
|
|
}
|
|
|
|
if (looksLikeFramedMessage(inputBuffer)) {
|
|
return;
|
|
}
|
|
|
|
const newlineIndex = inputBuffer.indexOf(0x0a);
|
|
|
|
if (newlineIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
const line = inputBuffer.subarray(0, newlineIndex).toString("utf8").trim();
|
|
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
|
|
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
transportMode = transportMode || "line";
|
|
void handleMessage(line);
|
|
}
|
|
}
|
|
|
|
async function handleMessage(payload) {
|
|
let request;
|
|
|
|
try {
|
|
request = JSON.parse(payload);
|
|
} catch {
|
|
writeMessage(jsonRpcError(null, -32700, "Parse error"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await forwardToEdgeEver(request);
|
|
|
|
if (response !== null) {
|
|
writeMessage(response);
|
|
}
|
|
} catch (error) {
|
|
writeMessage(
|
|
jsonRpcError(getJsonRpcId(request), -32000, error instanceof Error ? error.message : "MCP bridge failed")
|
|
);
|
|
}
|
|
}
|
|
|
|
async function forwardToEdgeEver(request) {
|
|
if (request?.method === "initialize") {
|
|
negotiatedProtocolVersion = request?.params?.protocolVersion || "2025-11-25";
|
|
}
|
|
|
|
const requestHeaders = createMcpHttpHeaders(request, negotiatedProtocolVersion);
|
|
|
|
const response = await fetch(`${client.baseUrl}/mcp`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${client.token}`,
|
|
Accept: "application/json, text/event-stream",
|
|
"Content-Type": "application/json",
|
|
...requestHeaders,
|
|
},
|
|
body: JSON.stringify(request),
|
|
});
|
|
|
|
if (response.status === 202 || response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
const body = await response.json().catch(() => null);
|
|
|
|
if (!body) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
if (request?.method === "initialize" && body?.result?.protocolVersion) {
|
|
negotiatedProtocolVersion = body.result.protocolVersion;
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
async function createClient(profileName) {
|
|
const directUrl = process.env.EDGEEVER_URL;
|
|
const directToken = process.env.EDGEEVER_TOKEN;
|
|
// ACP supplies both values for its short-lived local bridge. In that mode,
|
|
// do not read an unrelated MCP profile from the user's home directory.
|
|
const config = directUrl && directToken ? {} : await readConfig();
|
|
const profile = profileName ? config.profiles?.[profileName] : undefined;
|
|
const baseUrl = (directUrl || profile?.url || DEFAULT_URL).replace(/\/+$/, "");
|
|
const token = directToken || profile?.token;
|
|
|
|
if (!token) {
|
|
throw new Error("EDGEEVER_TOKEN is required, or configure a profile with `bun run cli -- profile set`.");
|
|
}
|
|
|
|
return { baseUrl, token };
|
|
}
|
|
|
|
async function readConfig() {
|
|
try {
|
|
const value = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
return value && typeof value === "object" ? value : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function readFramedMessage(buffer) {
|
|
const headerEnd = findHeaderEnd(buffer);
|
|
|
|
if (!headerEnd) {
|
|
return null;
|
|
}
|
|
|
|
const header = buffer.subarray(0, headerEnd.index).toString("utf8");
|
|
const match = /^content-length:\s*(\d+)\s*$/im.exec(header);
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const contentLength = Number(match[1]);
|
|
const bodyStart = headerEnd.index + headerEnd.length;
|
|
const bodyEnd = bodyStart + contentLength;
|
|
|
|
if (!Number.isInteger(contentLength) || contentLength < 0 || buffer.length < bodyEnd) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
payload: buffer.subarray(bodyStart, bodyEnd).toString("utf8"),
|
|
consumedBytes: bodyEnd,
|
|
};
|
|
}
|
|
|
|
function findHeaderEnd(buffer) {
|
|
const crlf = buffer.indexOf("\r\n\r\n");
|
|
|
|
if (crlf !== -1) {
|
|
return { index: crlf, length: 4 };
|
|
}
|
|
|
|
const lf = buffer.indexOf("\n\n");
|
|
return lf === -1 ? null : { index: lf, length: 2 };
|
|
}
|
|
|
|
function looksLikeFramedMessage(buffer) {
|
|
return buffer.subarray(0, Math.min(buffer.length, 32)).toString("utf8").toLowerCase().startsWith("content-length:");
|
|
}
|
|
|
|
function writeMessage(message) {
|
|
const payload = `${JSON.stringify(message)}\n`;
|
|
|
|
if (transportMode === "framed") {
|
|
const content = payload.trimEnd();
|
|
process.stdout.write(`Content-Length: ${Buffer.byteLength(content, "utf8")}\r\n\r\n${content}`);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(payload);
|
|
}
|
|
|
|
function parseOptions(args) {
|
|
const parsed = {};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
|
|
if (!arg.startsWith("--")) {
|
|
continue;
|
|
}
|
|
|
|
const key = arg.slice(2);
|
|
const next = args[index + 1];
|
|
|
|
if (!next || next.startsWith("--")) {
|
|
parsed[key] = "true";
|
|
continue;
|
|
}
|
|
|
|
parsed[key] = next;
|
|
index += 1;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function getJsonRpcId(request) {
|
|
if (!request || typeof request !== "object" || !("id" in request)) {
|
|
return null;
|
|
}
|
|
|
|
const id = request.id;
|
|
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
|
|
}
|
|
|
|
function jsonRpcError(id, code, message) {
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: {
|
|
code,
|
|
message,
|
|
},
|
|
};
|
|
}
|