File size: 7,201 Bytes
5c2a829 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | // MCP stdio server exposes OpenClaw tools over the MCP stdio transport.
import { AsyncLocalStorage } from "node:async_hooks";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { routeLogsToStderr } from "../logging/console.js";
import { LegacyPluginSdkResourceHost } from "../plugins/legacy-sdk-resource-host.js";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
import type { PluginToolRegistryAcquisition } from "../plugins/tools.js";
import { AsyncWorkScope, runWithTrackedCancellation } from "../shared/async-work-scope.js";
import { createDeferredCore } from "../shared/deferred.js";
import { VERSION } from "../version.js";
import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js";
class ToolsMcpServer extends Server {
#work = new AsyncWorkScope();
#closing: Promise<void> | undefined;
runRequest<T>(run: () => Promise<T>, signal: AbortSignal): Promise<T> {
// SDK request callbacks are queued in microtasks and may enter after transport closure.
if (this.#closing || !this.transport) {
return Promise.reject(McpError.fromError(ErrorCode.ConnectionClosed, "Connection closed"));
}
signal.throwIfAborted();
const work = this.#work;
return work.track(run);
}
override close(): Promise<void> {
if (this.#closing) {
return this.#closing;
}
const closed = createDeferredCore();
this.#closing = closed.promise;
const work = this.#work;
void (async () => {
try {
// Native close delivers cancellation; it does not join accepted tool work.
await super.close();
} finally {
await work.drain();
// The same SDK Server can connect again after this close has fully settled.
this.#work = new AsyncWorkScope();
this.#closing = undefined;
}
})().then(closed.resolve, closed.reject);
return closed.promise;
}
}
export function createToolsMcpServer(params: {
name: string;
tools: AnyAgentTool[];
sdkResourceHost?: LegacyPluginSdkResourceHost;
}): Server {
const handlers = createPluginToolsMcpHandlers(params.tools);
const server = new ToolsMcpServer(
{ name: params.name, version: VERSION },
{ capabilities: { tools: {} } },
);
const servingContext = params.sdkResourceHost?.run(() => AsyncLocalStorage.snapshot());
const runInServingContext = <T>(run: () => T): T =>
servingContext ? servingContext(run) : run();
server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
return await runInServingContext(() => server.runRequest(handlers.listTools, extra.signal));
});
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
// Restore serving authority before runRequest installs the accepted-work scope.
return await runInServingContext(() =>
server.runRequest(async () => {
if (!params.sdkResourceHost) {
return await handlers.callTool(request.params, extra.signal);
}
return await runWithTrackedCancellation(extra.signal, (signal) =>
handlers.callTool(request.params, signal),
);
}, extra.signal),
);
});
return server;
}
export async function connectToolsMcpServerToStdio(server: Server): Promise<void> {
// MCP stdio requires stdout to stay protocol-only.
routeLogsToStderr();
const closeFailures = new Set<unknown>();
let shuttingDown = false;
const shutdownComplete = createDeferredCore<unknown[]>();
const shutdown = () => {
if (shuttingDown) {
return;
}
shuttingDown = true;
process.stdin.off("end", shutdown);
process.stdin.off("close", shutdown);
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);
void (async () => {
try {
await server.close();
} catch (error) {
closeFailures.add(error);
} finally {
shutdownComplete.resolve([...closeFailures]);
}
})();
};
class OwnedStdioTransport extends StdioServerTransport {
override async close(): Promise<void> {
try {
await super.close();
} catch (error) {
// SDK self-close can consume this rejection before the serving owner sees it.
closeFailures.add(error);
throw error;
} finally {
shutdown();
}
}
}
const transport = new OwnedStdioTransport();
process.stdin.once("end", shutdown);
process.stdin.once("close", shutdown);
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
const failures: unknown[] = [];
try {
await server.connect(transport);
} catch (error) {
failures.push(error);
shutdown();
}
failures.push(...(await shutdownComplete.promise));
if (failures.length === 1) {
throw failures[0];
}
if (failures.length > 1) {
throw new AggregateError(failures, "MCP connection and transport shutdown failed");
}
}
/** Owns discovered registrations and nested SDK borrows for one terminal stdio service. */
export async function serveRegisteredToolsMcpServer(params: {
acquireRegistry: () => Promise<PluginToolRegistryAcquisition>;
createServer: (tools: AnyAgentTool[], sdkResourceHost: LegacyPluginSdkResourceHost) => Server;
}): Promise<void> {
const sdkResourceHost = new LegacyPluginSdkResourceHost();
let acquisition: PluginToolRegistryAcquisition | undefined;
const failures: unknown[] = [];
try {
await sdkResourceHost.run(async () => {
acquisition = await params.acquireRegistry();
const owned = acquisition;
await withPluginRuntimeRegistryScope(owned.registry, async () => {
const server = params.createServer(owned.resolveTools(), sdkResourceHost);
await connectToolsMcpServerToStdio(server);
});
});
} catch (error) {
failures.push(error);
}
try {
await sdkResourceHost.run(async () => {
const registry = acquisition?.registry;
if (registry?.agentHarnesses.length) {
try {
const { disposeRegisteredAgentHarnesses } = await import("../agents/harness/registry.js");
await withPluginRuntimeRegistryScope(registry, disposeRegisteredAgentHarnesses);
} catch (error) {
failures.push(error);
}
}
// SDK results may retain the same source; both owners must release before physical disposal.
const released = await Promise.allSettled([
Promise.resolve().then(() => acquisition?.release()),
Promise.resolve().then(() => sdkResourceHost.close()),
]);
for (const result of released) {
if (result.status === "rejected") {
failures.push(result.reason);
}
}
});
} catch (error) {
failures.push(error);
}
if (failures.length === 1) {
throw failures[0];
}
if (failures.length > 1) {
throw new AggregateError(failures, "MCP serving and registration cleanup failed");
}
}
|