Files
oh-my-pi/packages/coding-agent/src/mcp/client.ts
T
Miroslav Drbal [ApoC] 21a86a693d feat(mcp): implement roots/list and server-to-client request handling (#474)
* 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>
2026-03-18 23:02:22 +01:00

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;
}