Files
rea/src/server/RequestTrackingMcpServer.ts
T
Edgar Arakelyan 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

93 lines
3.1 KiB
TypeScript

import {
McpServer,
type Icon,
type RegisteredTool,
type ServerContext,
type ScopeChallengeHandler,
type StandardSchemaWithJSON,
type ToolAnnotations,
type ToolCallback,
} from "@modelcontextprotocol/server";
/** Tracks tools automatically; other resource-using MCP callbacks use runRequest. */
export class RequestTrackingMcpServer extends McpServer {
readonly #calls = new Set<Promise<unknown>>();
#tracking = false;
/** HTTP sessions share resources across transports; stdio keeps the SDK's original behavior. */
enableRequestTracking(): void {
this.#tracking = true;
}
override registerTool<
OutputArgs extends StandardSchemaWithJSON,
InputArgs extends StandardSchemaWithJSON | undefined = undefined,
>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
icons?: Icon[];
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
callback: ToolCallback<InputArgs>,
): RegisteredTool {
// The SDK calls a schema-less callback with context alone, and a schema'd
// callback with (validated input, context). Narrow its conditional type at
// the same inputSchema discriminant used by the SDK.
const tracked =
config.inputSchema === undefined
? (context: ServerContext) =>
this.runRequest(context.mcpReq.signal, () =>
(callback as ToolCallback<undefined>)(context),
)
: (args: unknown, context: ServerContext) =>
this.runRequest(context.mcpReq.signal, () =>
(callback as ToolCallback<StandardSchemaWithJSON>)(args, context),
);
return super.registerTool<OutputArgs, InputArgs>(
name,
config,
tracked as ToolCallback<InputArgs>,
);
}
/** Retain HTTP dispatch, tool and completion work under the HTTP owner's shared drain. */
runRequest<T>(
signal: AbortSignal | undefined,
callback: () => T | Promise<T>,
): T | Promise<T> {
if (!this.#tracking) return callback();
// SDK validation can finish after its transport closed. Such a callback
// must never start work against the next session's shared resources.
const call = Promise.resolve().then(() => {
signal?.throwIfAborted();
return callback();
});
this.#calls.add(call);
return call.finally(() => this.#calls.delete(call));
}
/** Wait for running handlers, including their finally blocks; a timeout retains ownership. */
async drainRequests(timeoutMs?: number): Promise<boolean> {
if (this.#calls.size === 0) return true;
const settled = Promise.allSettled(this.#calls).then(() => true);
if (timeoutMs === undefined) return settled;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
settled,
new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
}