-
Notifications
You must be signed in to change notification settings - Fork 152
Route MCP transports through the hosted HttpClient #1102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,8 +2,10 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. | |||||||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||||||||
| import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | ||||||||
| import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||||||||
| import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; | ||||||||
| import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; | ||||||||
| import { Effect, Predicate } from "effect"; | ||||||||
| import { Effect, Layer, Predicate, Stream } from "effect"; | ||||||||
| import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||||||||
|
|
||||||||
| // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module | ||||||||
| // (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process` | ||||||||
|
|
@@ -42,6 +44,7 @@ export type RemoteConnectorInput = Omit< | |||||||
| readonly headers?: Record<string, string>; | ||||||||
| readonly queryParams?: Record<string, string>; | ||||||||
| readonly authProvider?: OAuthClientProvider; | ||||||||
| readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | ||||||||
| }; | ||||||||
|
|
||||||||
| export type StdioConnectorInput = McpStdioIntegrationConfig; | ||||||||
|
|
@@ -60,6 +63,100 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record<string, string>) | |||||||
| return url; | ||||||||
| }; | ||||||||
|
|
||||||||
| type HttpMethod = Parameters<typeof HttpClientRequest.make>[0]; | ||||||||
| const HTTP_METHODS = new Set<HttpMethod>([ | ||||||||
| "DELETE", | ||||||||
| "GET", | ||||||||
| "HEAD", | ||||||||
| "OPTIONS", | ||||||||
| "PATCH", | ||||||||
| "POST", | ||||||||
| "PUT", | ||||||||
| ]); | ||||||||
|
|
||||||||
| const httpMethodFrom = (method: string | undefined): HttpMethod => { | ||||||||
| const normalized = (method ?? "GET").toUpperCase() as HttpMethod; | ||||||||
| return HTTP_METHODS.has(normalized) ? normalized : "POST"; | ||||||||
| }; | ||||||||
|
|
||||||||
| const headersFrom = (headers: HeadersInit | undefined): Headers => | ||||||||
| headers ? new Headers(headers) : new Headers(); | ||||||||
|
|
||||||||
| const recordFromHeaders = (headers: Headers): Record<string, string> => | ||||||||
| Object.fromEntries(headers.entries()); | ||||||||
|
|
||||||||
| const applyBody = async ( | ||||||||
| request: HttpClientRequest.HttpClientRequest, | ||||||||
| headers: Headers, | ||||||||
| body: BodyInit | null | undefined, | ||||||||
| ): Promise<HttpClientRequest.HttpClientRequest> => { | ||||||||
| if (body == null) return request; | ||||||||
| const contentType = headers.get("content-type") ?? undefined; | ||||||||
| if (typeof body === "string") return HttpClientRequest.bodyText(request, body, contentType); | ||||||||
| if (body instanceof URLSearchParams) { | ||||||||
| return HttpClientRequest.bodyText( | ||||||||
| request, | ||||||||
| body.toString(), | ||||||||
| contentType ?? "application/x-www-form-urlencoded;charset=UTF-8", | ||||||||
| ); | ||||||||
| } | ||||||||
| if (body instanceof Uint8Array) | ||||||||
| return HttpClientRequest.bodyUint8Array(request, body, contentType); | ||||||||
| if (body instanceof ArrayBuffer) { | ||||||||
| return HttpClientRequest.bodyUint8Array(request, new Uint8Array(body), contentType); | ||||||||
| } | ||||||||
| const bytes = new Uint8Array(await new Response(body).arrayBuffer()); | ||||||||
| return HttpClientRequest.bodyUint8Array(request, bytes, contentType); | ||||||||
| }; | ||||||||
|
|
||||||||
| const abortError = (signal: AbortSignal): unknown => { | ||||||||
| if (signal.reason !== undefined) return signal.reason; | ||||||||
| // oxlint-disable-next-line executor/no-error-constructor -- boundary: Fetch-compatible adapter must reject with an AbortError-shaped value | ||||||||
| const error = new Error("The operation was aborted"); | ||||||||
| error.name = "AbortError"; | ||||||||
| return error; | ||||||||
| }; | ||||||||
|
|
||||||||
| const fetchFromHttpClientLayer = ( | ||||||||
| httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | ||||||||
| ): FetchLike => { | ||||||||
| const execute: FetchLike = async (url, init) => { | ||||||||
| const headers = headersFrom(init?.headers); | ||||||||
| const requestWithoutBody = HttpClientRequest.make(httpMethodFrom(init?.method))(url, { | ||||||||
| headers: recordFromHeaders(headers), | ||||||||
| }); | ||||||||
| const request = await applyBody(requestWithoutBody, headers, init?.body); | ||||||||
| const effect = Effect.gen(function* () { | ||||||||
| const client = yield* HttpClient.HttpClient; | ||||||||
| const response = yield* client.execute(request); | ||||||||
| const responseHeaders = new Headers(); | ||||||||
| for (const [key, value] of Object.entries(response.headers)) { | ||||||||
| if (value !== undefined) responseHeaders.set(key, value); | ||||||||
| } | ||||||||
| const body = | ||||||||
| response.status === 204 || response.status === 205 || response.status === 304 | ||||||||
| ? null | ||||||||
| : Stream.toReadableStream(response.stream); | ||||||||
| return new Response(body, { | ||||||||
| status: response.status, | ||||||||
| headers: responseHeaders, | ||||||||
| }); | ||||||||
| }).pipe(Effect.provide(httpClientLayer)); | ||||||||
| const promise = Effect.runPromise(effect); | ||||||||
| if (!init?.signal) return promise; | ||||||||
| // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics | ||||||||
| if (init.signal.aborted) return Promise.reject(abortError(init.signal)); | ||||||||
| const aborted = new Promise<never>((_, reject) => { | ||||||||
| // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter races the Effect request against AbortSignal | ||||||||
| init.signal?.addEventListener("abort", () => reject(abortError(init.signal!)), { | ||||||||
| once: true, | ||||||||
| }); | ||||||||
| }); | ||||||||
| return Promise.race([promise, aborted]); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| }; | ||||||||
| return execute; | ||||||||
| }; | ||||||||
|
|
||||||||
| // Use the cfworker JSON Schema validator instead of the SDK's default | ||||||||
| // (Ajv). Ajv compiles schemas via `new Function(...)`, which throws | ||||||||
| // `Code generation from strings disallowed for this context` when the | ||||||||
|
|
@@ -157,6 +254,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { | |||||||
| const headers = input.headers ?? {}; | ||||||||
| const remoteTransport = input.remoteTransport ?? "auto"; | ||||||||
| const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; | ||||||||
| const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer) : undefined; | ||||||||
|
|
||||||||
| const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); | ||||||||
|
|
||||||||
|
|
@@ -166,6 +264,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { | |||||||
| new StreamableHTTPClientTransport(endpoint, { | ||||||||
| requestInit, | ||||||||
| authProvider: input.authProvider, | ||||||||
| fetch, | ||||||||
| }), | ||||||||
| }); | ||||||||
|
|
||||||||
|
|
@@ -175,6 +274,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { | |||||||
| new SSEClientTransport(endpoint, { | ||||||||
| requestInit, | ||||||||
| authProvider: input.authProvider, | ||||||||
| fetch, | ||||||||
| }), | ||||||||
| }); | ||||||||
|
|
||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
signalaborts andabortedwinsPromise.race, the function rejects correctly for the caller — butEffect.runPromise(effect)continues executing on its internal fiber until the request completes or fails. For SSE connections, which hold a long-lived stream, this means the stream is still consumed and the HTTP connection is still held open even after the MCP transport has declared the connection aborted. The fix requires spawning a fiber explicitly so it can be interrupted on abort:Without interruption, every aborted MCP connection leaks an open HTTP request for its remaining lifetime.