import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport, type StdioServerParameters, } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { CallToolResult, ListToolsResult } from "@modelcontextprotocol/sdk/types.js"; import { isLocalMcpHost } from "@rakazo/contracts"; import { combineSignals } from "./connector-safety.js"; import { createSafeRemoteFetch, type RemoteTransportDependencies, type SafeRemoteFetch, } from "./remote-mcp.js"; export type McpRemoteTransport = "streamable-http" | "sse"; export interface McpUrlPolicy { /** Maximum URL length accepted before any network request. */ maxUrlLength?: number; /** Permit plain HTTP only on the configured loopback resource's exact origin. */ allowHttpLocalhost?: boolean; /** Permit configured credentials on an explicitly local HTTP endpoint. */ allowLocalHttpCredentials?: boolean; /** Hosts allowed after redirects (redirects are rejected by default). */ allowedHosts?: readonly string[]; } export interface McpHeaderPolicy { /** Additional SDK request headers allowed beyond defaults and configured headers. */ allowedHeaders?: readonly string[]; headers?: Record; } export interface McpRemoteOptions { url: string | URL; transport?: McpRemoteTransport; urlPolicy?: McpUrlPolicy; headerPolicy?: McpHeaderPolicy; /** Opt in to the deprecated HTTP+SSE transport. */ allowLegacySse?: boolean; /** Fall back to SSE only after a fresh Streamable HTTP client fails. */ fallbackToSse?: boolean; /** Let the official SDK own token refresh, re-authentication, and request retry. */ authProvider?: OAuthClientProvider; network?: RemoteTransportDependencies; signal?: AbortSignal; timeoutMs?: number; } export interface McpStdioOptions { command: string; args?: string[]; cwd?: string; env?: Record; /** Exact executable allowlist. Stdio is otherwise disabled. */ allowedCommands: readonly string[]; maxBufferSize?: number; signal?: AbortSignal; timeoutMs?: number; } export interface McpClientOptions { name?: string; version?: string; capabilities?: ConstructorParameters[1]; } const DEFAULT_HEADERS = ["accept", "content-type", "authorization", "user-agent"]; const RESOURCE_HEADERS = new Set(["mcp-session-id", "mcp-protocol-version", "last-event-id"]); const DEFAULT_MAX_URL_LENGTH = 2_048; function validateUrl(raw: string | URL, policy: McpUrlPolicy = {}): URL { const url = new URL(raw.toString()); const max = policy.maxUrlLength ?? DEFAULT_MAX_URL_LENGTH; if (url.toString().length > max) throw new Error(`MCP URL exceeds ${max} characters`); if (url.username || url.password || url.hash) throw new Error("MCP URL must not contain credentials or a fragment"); const local = isLocalMcpHost(url.hostname); if ( url.protocol !== "https:" && !(url.protocol === "http:" && policy.allowHttpLocalhost === true && local) ) { throw new Error("MCP remote URL must use HTTPS (HTTP is allowed only for localhost)"); } if (policy.allowedHosts && !policy.allowedHosts.includes(url.hostname)) { throw new Error(`MCP host is not in the allowlist: ${url.hostname}`); } return url; } export function secureFetch( resourceUrl: URL, urlPolicy: McpUrlPolicy, headerPolicy: McpHeaderPolicy = {}, network: RemoteTransportDependencies = {}, ): SafeRemoteFetch { const localOrigin = urlPolicy.allowHttpLocalhost === true && resourceUrl.protocol === "http:" && isLocalMcpHost(resourceUrl.hostname) ? resourceUrl.origin : undefined; const allowed = new Set( [ ...DEFAULT_HEADERS, ...(headerPolicy.allowedHeaders ?? []), ...Object.keys(headerPolicy.headers ?? {}), ].map((header) => header.toLowerCase()), ); const configured = Object.entries(headerPolicy.headers ?? {}).filter(([name]) => allowed.has(name.toLowerCase()), ); const configuredValues = new Map( configured.map(([name, value]) => [name.toLowerCase(), value] as const), ); const configuredCredentialValues = new Set(configuredValues.values()); const configuredNames = new Set(configuredValues.keys()); const localCredentialHeaders = new Set([ ...configuredNames, "authorization", "cookie", "proxy-authorization", ]); const safeRemoteFetch = createSafeRemoteFetch(network.fetch, network.resolveHostname); const request = async (input: Request | URL | string, init?: RequestInit): Promise => { const source = new Request(input, init); // OAuth challenges and rediscovery can supply new URLs. Only the explicitly // configured local resource origin (including port) may bypass remote policy. const url = validateUrl(source.url, { ...urlPolicy, allowHttpLocalhost: new URL(source.url).origin === localOrigin, }); const headers = new Headers(); for (const [name, value] of source.headers) { const normalized = name.toLowerCase(); if (RESOURCE_HEADERS.has(normalized)) { if (url.origin !== resourceUrl.origin) continue; } else if (!allowed.has(normalized)) continue; if (url.origin !== resourceUrl.origin && configuredCredentialValues.has(value)) continue; headers.set(name, value); } const localHttp = url.protocol === "http:" && isLocalMcpHost(url.hostname); if (localHttp && urlPolicy.allowLocalHttpCredentials !== true) { for (const name of [...headers.keys()]) { if (localCredentialHeaders.has(name.toLowerCase())) headers.delete(name); } } if (!localHttp && url.origin === resourceUrl.origin) { for (const [name, value] of configured) headers.set(name, value); } // Buffer the body: a re-wrapped Request body is a stream without a replayable // source, and undici fails the whole request when a server answers 401 early // (the OAuth challenge) instead of draining it. const body = source.method === "GET" || source.method === "HEAD" ? undefined : await source.arrayBuffer(); const requestInit = { method: source.method, headers, body, redirect: "manual", signal: source.signal, } satisfies RequestInit; const response = localHttp ? await (network.fetch ?? globalThis.fetch)(url, requestInit) : await safeRemoteFetch(url, requestInit); if (response.status >= 300 && response.status < 400) { throw new Error("MCP redirects are not permitted; configure the final HTTPS URL explicitly"); } return response; }; const result = request as SafeRemoteFetch; result.close = () => safeRemoteFetch.close(); return result; } /** * Some providers (Brex) advertise OAuth discovery and registration hosts that * are not publicly routable, while mirroring the same well-known documents on * the MCP endpoint's own origin. When a request to another origin cannot * connect, retry the same path on the endpoint origin before giving up. */ export function withEndpointOriginFallback( endpointOrigin: string, fetchImpl: typeof fetch = fetch, ): typeof fetch { // Credentials must not be replayed to a different origin than the one the // SDK addressed, so the retry drops auth/session headers. const SENSITIVE_RETRY_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]); const sanitizedInit = (init?: RequestInit): RequestInit | undefined => { if (!init?.headers) return init; const headers = new Headers(init.headers); for (const name of [...headers.keys()]) { if (SENSITIVE_RETRY_HEADERS.has(name.toLowerCase())) headers.delete(name); } return { ...init, headers }; }; return async (input: Request | URL | string, init?: RequestInit): Promise => { if (input instanceof Request) return fetchImpl(input, init); const url = new URL(String(input)); if (url.origin === endpointOrigin) return fetchImpl(input, init); const method = (init?.method ?? "GET").toUpperCase(); if (method !== "GET" && method !== "HEAD") return fetchImpl(input, init); try { // Cap the first attempt: an unroutable host otherwise burns the full // connect timeout before the fallback gets a chance. return await fetchImpl(input, { ...init, signal: combineSignals(init?.signal ?? undefined, AbortSignal.timeout(4_000)), }); } catch { init?.signal?.throwIfAborted(); return fetchImpl(new URL(url.pathname + url.search, endpointOrigin), sanitizedInit(init)); } }; } function stdioParams(options: McpStdioOptions): StdioServerParameters { const command = options.command.trim(); if (!command || !options.allowedCommands.includes(command)) { throw new Error("MCP stdio command is not in the configured allowlist"); } const env: Record = {}; for (const [key, value] of Object.entries(options.env ?? {})) { if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) env[key] = value; } return { command, args: options.args ?? [], cwd: options.cwd, env, stderr: "pipe", maxBufferSize: options.maxBufferSize, }; } /** A narrow seam around the official SDK, suitable for the agent tool layer. */ export class McpSession { client: Client; private readonly clientOptions: McpClientOptions; private transport?: Transport; private remoteFetch?: SafeRemoteFetch; private connected = false; private connecting?: Promise; constructor(options: McpClientOptions = {}) { this.clientOptions = options; this.client = this.newClient(); } private newClient(): Client { return new Client( { name: this.clientOptions.name ?? "rakazo", version: this.clientOptions.version ?? "0.1.0" }, this.clientOptions.capabilities, ); } async connectRemote( options: McpRemoteOptions, ): Promise<{ transport: McpRemoteTransport; usedFallback: boolean }> { if (this.connected || this.connecting) throw new Error("MCP session is already connected or connecting"); const url = validateUrl(options.url, options.urlPolicy); const safeFetch = secureFetch( url, options.urlPolicy ?? {}, options.headerPolicy, options.network, ); this.remoteFetch = safeFetch; const fetch = withEndpointOriginFallback(url.origin, safeFetch); const signal = combineSignals(options.signal, AbortSignal.timeout(options.timeoutMs ?? 15_000)); let usedFallback = false; const connect = async (kind: McpRemoteTransport): Promise => { const requestInit: RequestInit = { headers: options.headerPolicy?.headers }; const transport = kind === "streamable-http" ? new StreamableHTTPClientTransport(url, { fetch, requestInit, authProvider: options.authProvider, }) : new SSEClientTransport(url, { fetch, requestInit, authProvider: options.authProvider, eventSourceInit: { fetch: fetch as never }, }); this.transport = transport; await this.client.connect(transport, { signal, timeout: options.timeoutMs ?? 15_000 }); this.connected = true; }; this.connecting = (async () => { try { await connect(options.transport ?? "streamable-http"); } catch (error) { const canFallback = (options.fallbackToSse ?? true) && options.allowLegacySse && (options.transport ?? "streamable-http") === "streamable-http"; await this.client.close().catch(() => undefined); this.connected = false; this.transport = undefined; if (!canFallback) { await this.remoteFetch?.close().catch(() => undefined); this.remoteFetch = undefined; throw error; } usedFallback = true; // The SDK's documented fallback uses a fresh Client after a failed // Streamable HTTP handshake; a Client cannot be reconnected safely. this.client = this.newClient(); try { await connect("sse"); } catch (fallbackError) { await this.close(); throw fallbackError; } } finally { this.connecting = undefined; } })(); await this.connecting; return { transport: usedFallback ? "sse" : (options.transport ?? "streamable-http"), usedFallback, }; } async connectStdio(options: McpStdioOptions): Promise { if (this.connected || this.connecting) throw new Error("MCP session is already connected or connecting"); const transport = new StdioClientTransport(stdioParams(options)); this.transport = transport; const signal = combineSignals(options.signal, AbortSignal.timeout(options.timeoutMs ?? 15_000)); this.connecting = this.client .connect(transport, { signal, timeout: options.timeoutMs ?? 15_000 }) .then(() => { this.connected = true; }) .finally(() => { this.connecting = undefined; }); await this.connecting; } async listTools(options?: { signal?: AbortSignal }): Promise { this.assertConnected(); return this.client.listTools({}, { signal: options?.signal }); } async callTool( name: string, args: Record = {}, options?: { signal?: AbortSignal }, ): Promise { this.assertConnected(); return (await this.client.callTool({ name, arguments: args }, undefined, { signal: options?.signal, })) as CallToolResult; } async close(): Promise { this.connecting = undefined; this.connected = false; await this.client.close().catch(() => undefined); await this.remoteFetch?.close().catch(() => undefined); this.remoteFetch = undefined; this.transport = undefined; } private assertConnected(): void { if (!this.connected) throw new Error("MCP session is not connected"); } } export { validateUrl };