Files
openclaw/src/gateway/mcp-http.ts
2026-05-15 21:47:46 +01:00

246 lines
7.7 KiB
TypeScript

import crypto from "node:crypto";
import {
createServer as createHttpServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { getRuntimeConfig } from "../config/io.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logDebug, logWarn } from "../logger.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
setActiveMcpLoopbackRuntime,
} from "./mcp-http.loopback-runtime.js";
import { jsonRpcError, type JsonRpcRequest } from "./mcp-http.protocol.js";
import {
readMcpHttpBody,
resolveMcpRequestContext,
validateMcpLoopbackRequest,
} from "./mcp-http.request.js";
import { McpLoopbackToolCache } from "./mcp-http.runtime.js";
export {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackBearerToken,
} from "./mcp-http.loopback-runtime.js";
type McpLoopbackServer = {
port: number;
close: () => Promise<void>;
};
let activeMcpLoopbackServer: McpLoopbackServer | undefined;
let activeMcpLoopbackServerPromise: Promise<McpLoopbackServer> | null = null;
function shouldLogMcpLoopbackTraffic(): boolean {
return (
isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) ||
isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG)
);
}
function logMcpLoopbackTraffic(step: string, details: Record<string, unknown>): void {
if (!shouldLogMcpLoopbackTraffic()) {
return;
}
console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) {
const controller = new AbortController();
const abort = () => {
if (!controller.signal.aborted) {
controller.abort();
}
};
const abortIfRequestIncomplete = () => {
if (!req.complete) {
abort();
}
};
const abortIfResponseStillOpen = () => {
if (!res.writableEnded) {
abort();
}
};
req.once("close", abortIfRequestIncomplete);
res.once("close", abortIfResponseStillOpen);
if (req.destroyed && !req.complete) {
abort();
}
return {
signal: controller.signal,
cleanup: () => {
req.off("close", abortIfRequestIncomplete);
res.off("close", abortIfResponseStillOpen);
},
};
}
export async function startMcpLoopbackServer(port = 0): Promise<{
port: number;
close: () => Promise<void>;
}> {
const ownerToken = crypto.randomBytes(32).toString("hex");
const nonOwnerToken = crypto.randomBytes(32).toString("hex");
const toolCache = new McpLoopbackToolCache();
const httpServer = createHttpServer((req, res) => {
const auth = validateMcpLoopbackRequest({ req, res, ownerToken, nonOwnerToken });
if (!auth) {
return;
}
const requestAbort = createRequestAbortSignal(req, res);
void (async () => {
try {
const body = await readMcpHttpBody(req);
const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body);
const cfg = getRuntimeConfig();
const requestContext = resolveMcpRequestContext(req, cfg, auth);
const scopedTools = toolCache.resolve({
cfg,
sessionKey: requestContext.sessionKey,
messageProvider: requestContext.messageProvider,
accountId: requestContext.accountId,
inboundTurnKind: requestContext.inboundTurnKind,
senderIsOwner: requestContext.senderIsOwner,
});
const messages = Array.isArray(parsed) ? parsed : [parsed];
logMcpLoopbackTraffic("request", {
batchSize: messages.length,
methods: messages.map((message) => message.method),
sessionKey: requestContext.sessionKey,
inboundTurnKind: requestContext.inboundTurnKind,
senderIsOwner: requestContext.senderIsOwner,
toolCount: scopedTools.toolSchema.length,
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),
});
const responses: object[] = [];
for (const message of messages) {
const response = await handleMcpJsonRpc({
message,
tools: scopedTools.tools,
toolSchema: scopedTools.toolSchema,
hookContext: {
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
},
signal: requestAbort.signal,
});
if (response !== null) {
const toolName =
message.method === "tools/call" && isRecord(message.params)
? message.params.name
: undefined;
const isError =
isRecord(response) && isRecord(response.result) && response.result.isError === true;
logMcpLoopbackTraffic("response", {
method: message.method,
toolName: typeof toolName === "string" ? toolName : undefined,
isError,
});
responses.push(response);
}
}
if (responses.length === 0) {
res.writeHead(202);
res.end();
return;
}
const payload = Array.isArray(parsed)
? JSON.stringify(responses)
: JSON.stringify(responses[0]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(payload);
} catch (error) {
logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`);
logMcpLoopbackTraffic("request-failed", {
message: formatErrorMessage(error),
});
if (!res.headersSent) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error")));
}
} finally {
requestAbort.cleanup();
}
})();
});
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => {
httpServer.removeListener("error", reject);
resolve();
});
});
const address = httpServer.address();
if (!address || typeof address === "string") {
throw new Error("mcp loopback did not bind to a TCP port");
}
setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken });
logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`);
const server: McpLoopbackServer = {
port: address.port,
close: () =>
new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (!error) {
clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken);
if (activeMcpLoopbackServer === server) {
activeMcpLoopbackServer = undefined;
}
}
if (error) {
reject(error);
return;
}
resolve();
});
}),
};
return server;
}
export async function ensureMcpLoopbackServer(port = 0): Promise<McpLoopbackServer> {
if (activeMcpLoopbackServer) {
return activeMcpLoopbackServer;
}
if (!activeMcpLoopbackServerPromise) {
activeMcpLoopbackServerPromise = startMcpLoopbackServer(port)
.then((server) => {
activeMcpLoopbackServer = server;
return server;
})
.finally(() => {
activeMcpLoopbackServerPromise = null;
});
}
return activeMcpLoopbackServerPromise;
}
export async function closeMcpLoopbackServer(): Promise<void> {
const server =
activeMcpLoopbackServer ??
(activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined);
if (!server) {
return;
}
activeMcpLoopbackServer = undefined;
await server.close();
}