diff --git a/README.md b/README.md index 7512ae7..44e14ea 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ This enables seamless session resumption without manual intervention. - Ensure your MCP server has CORS properly configured - Check that the server URL is correct and publicly accessible - Verify that the server supports the Streamable HTTP transport +- Air Agent includes built-in compatibility for MCP servers with limited CORS support, automatically working around common CORS preflight issues **Tools Not Appearing** - Make sure the MCP server is enabled in the configuration diff --git a/lib/mcp/client.ts b/lib/mcp/client.ts index cab6861..e2ecf36 100644 --- a/lib/mcp/client.ts +++ b/lib/mcp/client.ts @@ -10,19 +10,93 @@ * - Session IDs are stored in memory for the duration of the browser session (while page is loaded) * - The underlying transport automatically includes the session ID in request headers * - Note: Session persistence across page reloads is not supported + * + * CORS Compatibility: + * - Uses BrowserCompatibleTransport wrapper to prevent mcp-protocol-version header from being sent + * - This avoids CORS preflight requests that many MCP servers don't handle properly + * - Protocol version is still negotiated via the initialize request/response body + * - Maintains full MCP protocol compatibility while improving browser compatibility */ import { Client } from "@modelcontextprotocol/sdk/client" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { McpServerConfig, McpConnectionStatus, McpTool } from "./types" import { Tool } from "../tools" +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js" +import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js" + +/** + * Transport wrapper that prevents the mcp-protocol-version header from being sent. + * + * This is necessary to avoid CORS preflight issues with some MCP servers that don't + * properly configure CORS for custom headers. By preventing the protocol version + * header from being sent, we avoid triggering CORS preflight requests. + * + * Note: The protocol version is still negotiated during the initialize handshake + * (via the request/response body), so this doesn't affect protocol compatibility. + * + * @internal This is an implementation detail of McpClient and should not be used directly. + * It is exported for testing purposes only. + */ +export class BrowserCompatibleTransport implements Transport { + private innerTransport: StreamableHTTPClientTransport + + constructor(innerTransport: StreamableHTTPClientTransport) { + this.innerTransport = innerTransport + } + + async start(): Promise { + return this.innerTransport.start() + } + + async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + return this.innerTransport.send(message) + } + + async close(): Promise { + return this.innerTransport.close() + } + + set onclose(handler: (() => void) | undefined) { + this.innerTransport.onclose = handler + } + + get onclose(): (() => void) | undefined { + return this.innerTransport.onclose + } + + set onerror(handler: ((error: Error) => void) | undefined) { + this.innerTransport.onerror = handler + } + + get onerror(): ((error: Error) => void) | undefined { + return this.innerTransport.onerror + } + + set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { + this.innerTransport.onmessage = handler + } + + get onmessage(): ((message: JSONRPCMessage) => void) | undefined { + return this.innerTransport.onmessage + } + + get sessionId(): string | undefined { + return this.innerTransport.sessionId + } + + // Intentionally NOT implementing setProtocolVersion to prevent the + // mcp-protocol-version header from being added to requests. + // This avoids CORS preflight issues with servers that don't properly + // configure CORS for custom headers. +} /** * MCP Client wrapper for browser use */ export class McpClient { private client: Client | null = null - private transport: StreamableHTTPClientTransport | null = null + private transport: BrowserCompatibleTransport | null = null private config: McpServerConfig private statusCallback?: (status: McpConnectionStatus, error?: string) => void private sessionId: string | undefined @@ -57,7 +131,7 @@ export class McpClient { // MCD's MCP doc uses the origin URL directly (e.g. https://mcp.mcd.cn). // Some deployments may respond 405 for GET /; we treat that as ignorable. const effectiveUrl = normalizeMcpEndpointUrl(this.config.url) - this.transport = new StreamableHTTPClientTransport(new URL(effectiveUrl), { + const innerTransport = new StreamableHTTPClientTransport(new URL(effectiveUrl), { requestInit: { headers, }, @@ -66,6 +140,10 @@ export class McpClient { // Note: Session resumption across page loads is not supported by most MCP servers }) + // Wrap transport to prevent mcp-protocol-version header from being sent + // This avoids CORS preflight issues with servers that don't properly configure CORS + this.transport = new BrowserCompatibleTransport(innerTransport) + // Create MCP client this.client = new Client( { @@ -79,11 +157,11 @@ export class McpClient { } ) - // Connect to the server + // Connect to the server using the wrapped transport try { await this.client.connect(this.transport) // Read session ID from response header (set by the transport after initial request) - // The transport receives the mcp-session-id header from the server and stores it + // The transport wrapper delegates sessionId to the inner transport const receivedSessionId = this.transport.sessionId if (receivedSessionId) { this.sessionId = receivedSessionId