21a86a693d
* feat(mcp): implement roots/list and server-to-client request handling
Add support for MCP server-to-client JSON-RPC requests across both
stdio and HTTP transports, enabling servers to query client capabilities
such as roots/list during initialization.
Transport layer (types.ts, stdio.ts, http.ts):
- Add onRequest callback to MCPTransport interface for server-initiated
requests; add toJsonRpcError helper for error code propagation
- Classify incoming messages by checking method+id (request), id-only
(response), method-only (notification); guard against id:null per
JSON-RPC 2.0 spec
- StdioTransport: detect server requests in #handleMessage, respond via
#sendResponse writing JSON-RPC response to subprocess stdin
- HttpTransport: detect server requests via #dispatchSSEMessage across
all SSE streams (dedicated listener, POST response drain, notify
piggybacking), respond via #sendServerResponse POST with proper
Accept header and session ID
- Refactor startSSEListener to resolve once SSE GET connects (not when
stream ends), enabling await before notifications/initialized; reset
#sseConnection via .finally() for reconnection after transient failure
- #parseSSEResponse continues reading after capturing the primary
response to drain piggybacked server requests/notifications; clears
timeout after capture so drain phase is unbounded
- notify() reads text/event-stream response bodies for piggybacked
messages; cancels non-SSE response bodies to release connections
- #sendServerResponse includes AbortSignal.timeout and cancels response
body; fire-and-forget handlers wrapped in try/catch to prevent
unhandled rejections
Client wiring (client.ts):
- Add onRequest to connectToServer options, wire to transport before
initialization
- Add awaitable onInitialized hook in initializeConnection, called
between initialize response (which sets session ID) and initialized
notification, so SSE stream is open when server sends roots/list
- Pass only signal to transport.request (not full options object)
- Hoist transport ref to outer scope; close on timeout/abort to prevent
orphaned transports when SSE GET hangs
Manager (manager.ts):
- Wire onRequest handler in connectServers for all MCP connections
- Handle roots/list by returning project CWD as file:// URI via
pathToFileURL; return -32601 for unsupported methods
Tests (mcp-roots-list.test.ts):
- toJsonRpcError: code extraction, defaults, non-Error values
- Message classification spec tests: request/response/notification/
unknown dispatch, id:null and id:0 edge cases
- Roots response shape: file:// URI generation, Windows paths, spaces
* fix(mcp): return SSE response immediately instead of blocking on stream drain
The #parseSSEResponse loop continued iterating the SSE stream after
capturing the response for the expected request ID. Since clearTimeout
was called after capture, a server that holds the SSE stream open for
follow-up events (permitted by Streamable HTTP) would block the
request() call indefinitely.
Return the result as soon as it's captured and drain remaining
messages in a detached background task via #readSSEStream, which
already handles dispatch and error swallowing.
* fix(mcp): handle batched JSON-RPC messages in both transports
JSON-RPC 2.0 section 6 allows sending an array of request/notification
objects as a batch. If a server sent a batch, the message classifier
in both transports would fail the 'method in message' check on the
array object and silently drop all contained messages.
Add an Array.isArray guard at the top of #handleMessage (stdio) and
#dispatchSSEMessage (http) that recurses into each element. Defensive
measure — no known MCP server sends batches today, but the guard is
cheap and correct per the JSON-RPC spec.
* fix(mcp): address second Codex review round
- http: break from SSE loop before starting background drain to avoid
ReadableStream locked error (the for-await iterator still holds the
reader when #drainSSEBackground was called inline)
- types: toJsonRpcError now accepts plain { code, message } objects,
not just Error instances, so onRequest handlers can throw structured
JSON-RPC errors without wrapping in Error
- test: relax Windows path name assertion to toBeTruthy since
path.basename is platform-dependent for backslash paths; add tests
for plain-object toJsonRpcError
* fix(mcp): address third Codex review round
- parseSSEResponse: flatten JSON-RPC batch arrays before checking for
the expected response, so a server that batches the primary response
with piggybacked requests/notifications in a single SSE event still
has the response extracted correctly
- sendServerResponse: retry once on 401/403 via onAuthError, matching
the auth-refresh logic in #executeRequest; prevents server-initiated
request replies from failing after token expiry on long-lived SSE
sessions
---------
Co-authored-by: Miroslav Drbal <miroslav.drbal@gendigital.com>
458 lines
11 KiB
TypeScript
458 lines
11 KiB
TypeScript
/**
|
|
* MCP Client.
|
|
*
|
|
* Handles connection initialization, tool listing, and tool calling.
|
|
*/
|
|
import { logger, withTimeout } from "@oh-my-pi/pi-utils";
|
|
import { createHttpTransport } from "./transports/http";
|
|
import { createStdioTransport } from "./transports/stdio";
|
|
import type {
|
|
MCPGetPromptParams,
|
|
MCPGetPromptResult,
|
|
MCPHttpServerConfig,
|
|
MCPInitializeParams,
|
|
MCPInitializeResult,
|
|
MCPPrompt,
|
|
MCPPromptsListResult,
|
|
MCPRequestOptions,
|
|
MCPResource,
|
|
MCPResourceReadParams,
|
|
MCPResourceReadResult,
|
|
MCPResourceSubscribeParams,
|
|
MCPResourcesListResult,
|
|
MCPResourceTemplate,
|
|
MCPResourceTemplatesListResult,
|
|
MCPServerCapabilities,
|
|
MCPServerConfig,
|
|
MCPServerConnection,
|
|
MCPSseServerConfig,
|
|
MCPStdioServerConfig,
|
|
MCPToolCallParams,
|
|
MCPToolCallResult,
|
|
MCPToolDefinition,
|
|
MCPToolsListResult,
|
|
MCPTransport,
|
|
} from "./types";
|
|
|
|
/** MCP protocol version we support */
|
|
const PROTOCOL_VERSION = "2025-03-26";
|
|
|
|
/** Default connection timeout in ms */
|
|
const CONNECTION_TIMEOUT_MS = 30_000;
|
|
|
|
/** Client info sent during initialization */
|
|
const CLIENT_INFO = {
|
|
name: "omp-coding-agent",
|
|
version: "1.0.0",
|
|
};
|
|
|
|
/**
|
|
* Create a transport for the given server config.
|
|
*/
|
|
async function createTransport(config: MCPServerConfig): Promise<MCPTransport> {
|
|
const serverType = config.type ?? "stdio";
|
|
|
|
switch (serverType) {
|
|
case "stdio":
|
|
return createStdioTransport(config as MCPStdioServerConfig);
|
|
case "http":
|
|
case "sse":
|
|
return createHttpTransport(config as MCPHttpServerConfig | MCPSseServerConfig);
|
|
default:
|
|
throw new Error(`Unknown server type: ${serverType}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize connection with MCP server.
|
|
*/
|
|
async function initializeConnection(
|
|
transport: MCPTransport,
|
|
options?: {
|
|
signal?: AbortSignal;
|
|
/** Called after the initialize response (which sets the session ID) but before notifications/initialized. */
|
|
onInitialized?: () => void | Promise<void>;
|
|
},
|
|
): Promise<MCPInitializeResult> {
|
|
const params: MCPInitializeParams = {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: {
|
|
roots: { listChanged: false },
|
|
},
|
|
clientInfo: CLIENT_INFO,
|
|
};
|
|
|
|
const result = await transport.request<MCPInitializeResult>(
|
|
"initialize",
|
|
params as unknown as Record<string, unknown>,
|
|
{ signal: options?.signal },
|
|
);
|
|
|
|
if (options?.signal?.aborted) {
|
|
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
|
|
}
|
|
|
|
// Hook point: the transport now has the session ID from the initialize response.
|
|
// For HTTP, this is the moment to open the SSE stream so server-to-client requests
|
|
// triggered by notifications/initialized (e.g. roots/list) can be delivered.
|
|
await options?.onInitialized?.();
|
|
|
|
// Send initialized notification
|
|
await transport.notify("notifications/initialized");
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Connect to an MCP server.
|
|
* Has a 30 second timeout to prevent blocking startup.
|
|
*/
|
|
export async function connectToServer(
|
|
name: string,
|
|
config: MCPServerConfig,
|
|
options?: {
|
|
signal?: AbortSignal;
|
|
onNotification?: (method: string, params: unknown) => void;
|
|
onRequest?: (method: string, params: unknown) => Promise<unknown>;
|
|
},
|
|
): Promise<MCPServerConnection> {
|
|
const timeoutMs = config.timeout ?? CONNECTION_TIMEOUT_MS;
|
|
let transport: MCPTransport | undefined;
|
|
|
|
const connect = async (): Promise<MCPServerConnection> => {
|
|
transport = await createTransport(config);
|
|
if (options?.onNotification) {
|
|
transport.onNotification = options.onNotification;
|
|
}
|
|
if (options?.onRequest) {
|
|
transport.onRequest = options.onRequest;
|
|
}
|
|
|
|
try {
|
|
const initResult = await initializeConnection(transport, {
|
|
signal: options?.signal,
|
|
async onInitialized() {
|
|
// Open the SSE stream before sending initialized, so server-to-client
|
|
// requests triggered by on_initialized (e.g. roots/list) are delivered.
|
|
if ("startSSEListener" in transport! && typeof transport!.startSSEListener === "function") {
|
|
await (transport as { startSSEListener(): Promise<void> }).startSSEListener();
|
|
}
|
|
},
|
|
});
|
|
|
|
return {
|
|
name,
|
|
config,
|
|
transport,
|
|
serverInfo: initResult.serverInfo,
|
|
capabilities: initResult.capabilities,
|
|
instructions: initResult.instructions,
|
|
};
|
|
} catch (error) {
|
|
await transport.close();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
try {
|
|
return await withTimeout(
|
|
connect(),
|
|
timeoutMs,
|
|
`Connection to MCP server "${name}" timed out after ${timeoutMs}ms`,
|
|
options?.signal,
|
|
);
|
|
} catch (error) {
|
|
// If withTimeout rejected (timeout/abort) while connect() was still pending,
|
|
// the transport may be alive with an open SSE listener. Close it.
|
|
if (transport) {
|
|
void transport.close().catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List tools from a connected server.
|
|
*/
|
|
export async function listTools(
|
|
connection: MCPServerConnection,
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<MCPToolDefinition[]> {
|
|
// Check if server supports tools
|
|
if (!connection.capabilities.tools) {
|
|
return [];
|
|
}
|
|
|
|
// Return cached tools if available
|
|
if (connection.tools) {
|
|
return connection.tools;
|
|
}
|
|
|
|
const allTools: MCPToolDefinition[] = [];
|
|
let cursor: string | undefined;
|
|
|
|
do {
|
|
const params: Record<string, unknown> = {};
|
|
if (cursor) {
|
|
params.cursor = cursor;
|
|
}
|
|
|
|
const result = await connection.transport.request<MCPToolsListResult>("tools/list", params, options);
|
|
allTools.push(...result.tools);
|
|
cursor = result.nextCursor;
|
|
} while (cursor);
|
|
|
|
// Cache tools
|
|
connection.tools = allTools;
|
|
|
|
return allTools;
|
|
}
|
|
|
|
/**
|
|
* Call a tool on a connected server.
|
|
*/
|
|
export async function callTool(
|
|
connection: MCPServerConnection,
|
|
toolName: string,
|
|
args: Record<string, unknown> = {},
|
|
options?: MCPRequestOptions,
|
|
): Promise<MCPToolCallResult> {
|
|
const params: MCPToolCallParams = {
|
|
name: toolName,
|
|
arguments: args,
|
|
};
|
|
|
|
return connection.transport.request<MCPToolCallResult>(
|
|
"tools/call",
|
|
params as unknown as Record<string, unknown>,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Disconnect from a server.
|
|
*/
|
|
export async function disconnectServer(connection: MCPServerConnection): Promise<void> {
|
|
await connection.transport.close();
|
|
}
|
|
|
|
/**
|
|
* Check if a server supports tools.
|
|
*/
|
|
export function serverSupportsTools(capabilities: MCPServerCapabilities): boolean {
|
|
return capabilities.tools !== undefined;
|
|
}
|
|
|
|
/**
|
|
* List resources from a connected server.
|
|
*/
|
|
export async function listResources(
|
|
connection: MCPServerConnection,
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<MCPResource[]> {
|
|
if (!connection.capabilities.resources) {
|
|
return [];
|
|
}
|
|
|
|
if (connection.resources) {
|
|
return connection.resources;
|
|
}
|
|
|
|
const allResources: MCPResource[] = [];
|
|
let cursor: string | undefined;
|
|
|
|
do {
|
|
const params: Record<string, unknown> = {};
|
|
if (cursor) {
|
|
params.cursor = cursor;
|
|
}
|
|
|
|
const result = await connection.transport.request<MCPResourcesListResult>("resources/list", params, options);
|
|
allResources.push(...result.resources);
|
|
cursor = result.nextCursor;
|
|
} while (cursor);
|
|
|
|
connection.resources = allResources;
|
|
return allResources;
|
|
}
|
|
|
|
/**
|
|
* List resource templates from a connected server.
|
|
*/
|
|
export async function listResourceTemplates(
|
|
connection: MCPServerConnection,
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<MCPResourceTemplate[]> {
|
|
if (!connection.capabilities.resources) {
|
|
return [];
|
|
}
|
|
|
|
if (connection.resourceTemplates) {
|
|
return connection.resourceTemplates;
|
|
}
|
|
|
|
const allTemplates: MCPResourceTemplate[] = [];
|
|
let cursor: string | undefined;
|
|
|
|
do {
|
|
const params: Record<string, unknown> = {};
|
|
if (cursor) {
|
|
params.cursor = cursor;
|
|
}
|
|
|
|
const result = await connection.transport.request<MCPResourceTemplatesListResult>(
|
|
"resources/templates/list",
|
|
params,
|
|
options,
|
|
);
|
|
allTemplates.push(...result.resourceTemplates);
|
|
cursor = result.nextCursor;
|
|
} while (cursor);
|
|
|
|
connection.resourceTemplates = allTemplates;
|
|
return allTemplates;
|
|
}
|
|
|
|
/**
|
|
* Read a resource from a connected server.
|
|
*/
|
|
export async function readResource(
|
|
connection: MCPServerConnection,
|
|
uri: string,
|
|
options?: MCPRequestOptions,
|
|
): Promise<MCPResourceReadResult> {
|
|
const params: MCPResourceReadParams = { uri };
|
|
return connection.transport.request<MCPResourceReadResult>(
|
|
"resources/read",
|
|
params as unknown as Record<string, unknown>,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Subscribe to resource update notifications.
|
|
*/
|
|
export async function subscribeToResources(
|
|
connection: MCPServerConnection,
|
|
uris: string[],
|
|
options?: MCPRequestOptions,
|
|
): Promise<void> {
|
|
if (uris.length === 0 || !connection.capabilities.resources?.subscribe) return;
|
|
const results = await Promise.allSettled(
|
|
uris.map(uri => {
|
|
const params: MCPResourceSubscribeParams = { uri };
|
|
return connection.transport.request(
|
|
"resources/subscribe",
|
|
params as unknown as Record<string, unknown>,
|
|
options,
|
|
);
|
|
}),
|
|
);
|
|
for (const result of results) {
|
|
if (result.status === "rejected") {
|
|
logger.warn("Failed to subscribe to MCP resource", { error: result.reason });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from resource update notifications.
|
|
*/
|
|
export async function unsubscribeFromResources(
|
|
connection: MCPServerConnection,
|
|
uris: string[],
|
|
options?: MCPRequestOptions,
|
|
): Promise<void> {
|
|
if (uris.length === 0 || !connection.capabilities.resources?.subscribe) return;
|
|
const results = await Promise.allSettled(
|
|
uris.map(uri => {
|
|
const params: MCPResourceSubscribeParams = { uri };
|
|
return connection.transport.request(
|
|
"resources/unsubscribe",
|
|
params as unknown as Record<string, unknown>,
|
|
options,
|
|
);
|
|
}),
|
|
);
|
|
for (const result of results) {
|
|
if (result.status === "rejected") {
|
|
logger.warn("Failed to unsubscribe from MCP resource", { error: result.reason });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a server supports resource subscriptions.
|
|
*/
|
|
export function serverSupportsResourceSubscriptions(capabilities: MCPServerCapabilities): boolean {
|
|
return capabilities.resources?.subscribe === true;
|
|
}
|
|
|
|
/**
|
|
* Check if a server supports resources.
|
|
*/
|
|
export function serverSupportsResources(capabilities: MCPServerCapabilities): boolean {
|
|
return capabilities.resources !== undefined;
|
|
}
|
|
|
|
/**
|
|
* List prompts from a connected server.
|
|
*/
|
|
export async function listPrompts(
|
|
connection: MCPServerConnection,
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<MCPPrompt[]> {
|
|
if (!connection.capabilities.prompts) {
|
|
return [];
|
|
}
|
|
|
|
if (connection.prompts) {
|
|
return connection.prompts;
|
|
}
|
|
|
|
const allPrompts: MCPPrompt[] = [];
|
|
let cursor: string | undefined;
|
|
|
|
do {
|
|
const params: Record<string, unknown> = {};
|
|
if (cursor) {
|
|
params.cursor = cursor;
|
|
}
|
|
|
|
const result = await connection.transport.request<MCPPromptsListResult>("prompts/list", params, options);
|
|
allPrompts.push(...result.prompts);
|
|
cursor = result.nextCursor;
|
|
} while (cursor);
|
|
|
|
connection.prompts = allPrompts;
|
|
return allPrompts;
|
|
}
|
|
|
|
/**
|
|
* Get a specific prompt from a connected server.
|
|
*/
|
|
export async function getPrompt(
|
|
connection: MCPServerConnection,
|
|
name: string,
|
|
args?: Record<string, string>,
|
|
options?: MCPRequestOptions,
|
|
): Promise<MCPGetPromptResult> {
|
|
const params: MCPGetPromptParams = { name };
|
|
if (args && Object.keys(args).length > 0) {
|
|
params.arguments = args;
|
|
}
|
|
|
|
return connection.transport.request<MCPGetPromptResult>(
|
|
"prompts/get",
|
|
params as unknown as Record<string, unknown>,
|
|
options,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a server supports prompts.
|
|
*/
|
|
export function serverSupportsPrompts(capabilities: MCPServerCapabilities): boolean {
|
|
return capabilities.prompts !== undefined;
|
|
}
|