Files
oh-my-pi/packages/coding-agent/src/mcp/client.ts
T
can1357 8d10a6b879 feat(coding-agent): added abort signal support and improved cancellation handling in MCP server operations
- Added abort signal support to MCP server connection and tool listing operations, enabling cancellation via Escape key during testing.
- Enhanced MCP connection timeout handling with improved abort signal integration in the withTimeout function to prevent race conditions.
- Improved MCP test command UI to display '(esc to cancel)' indicator and handle cancellation gracefully.
- Updated HTTP transport session termination to include timeout mechanism preventing indefinite hangs.
- Fixed MCP test command cleanup to prevent resource leaks when operations are cancelled or aborted.
2026-02-10 17:33:46 +01:00

240 lines
5.5 KiB
TypeScript

/**
* MCP Client.
*
* Handles connection initialization, tool listing, and tool calling.
*/
import { createHttpTransport } from "./transports/http";
import { createStdioTransport } from "./transports/stdio";
import type {
MCPHttpServerConfig,
MCPInitializeParams,
MCPInitializeResult,
MCPRequestOptions,
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",
};
/** Wrap a promise with a timeout and optional abort signal */
function withTimeout<T>(promise: Promise<T>, ms: number, message: string, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
return Promise.reject(reason);
}
const { promise: wrapped, resolve, reject } = Promise.withResolvers<T>();
let settled = false;
const timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(message));
}, ms);
const onAbort = () => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
promise.then(
value => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
if (signal) signal.removeEventListener("abort", onAbort);
resolve(value);
},
error => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
if (signal) signal.removeEventListener("abort", onAbort);
reject(error);
},
);
return wrapped;
}
/**
* 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 },
): 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>,
options,
);
if (options?.signal?.aborted) {
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
}
// 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 },
): Promise<MCPServerConnection> {
const timeoutMs = config.timeout ?? CONNECTION_TIMEOUT_MS;
const connect = async (): Promise<MCPServerConnection> => {
const transport = await createTransport(config);
try {
const initResult = await initializeConnection(transport, options);
return {
name,
config,
transport,
serverInfo: initResult.serverInfo,
capabilities: initResult.capabilities,
};
} catch (error) {
await transport.close();
throw error;
}
};
return withTimeout(
connect(),
timeoutMs,
`Connection to MCP server "${name}" timed out after ${timeoutMs}ms`,
options?.signal,
);
}
/**
* 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;
}