From 412601ee85b530a48797022f1156a08a4f7c1f03 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 11:11:41 -0700 Subject: [PATCH 1/8] feat(ai): let McpServer.layerHttp terminate sessions on DELETE With allowSessionTermination, a DELETE carrying an Mcp-Session-Id ends that HTTP session (204, 404 for an unknown session, 400 without the header), and later requests with the id get 404 so the client re-initializes. Without it, DELETE keeps returning 405, which the spec allows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/mcp-http-session-termination.md | 5 +++ packages/effect/src/ai/McpServer.ts | 32 ++++++++++++++++--- packages/effect/src/ai/internal/mcpRuntime.ts | 3 ++ .../src/ai/internal/mcpStatefulRuntime.ts | 3 ++ .../test/ai/McpServer/McpServer.test.ts | 21 +++++++++++- .../ai/McpServer/TestUtils/McpServerLayer.ts | 4 ++- 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-http-session-termination.md diff --git a/.changeset/mcp-http-session-termination.md b/.changeset/mcp-http-session-termination.md new file mode 100644 index 00000000000..f27d927611b --- /dev/null +++ b/.changeset/mcp-http-session-termination.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add `allowSessionTermination` to `McpServer.layerHttp`. When set, a DELETE with an `Mcp-Session-Id` ends that session (`204`), and later requests with the id get `404` so the client re-initializes. Without it, DELETE still returns `405`. diff --git a/packages/effect/src/ai/McpServer.ts b/packages/effect/src/ai/McpServer.ts index f76029cfb18..04cd1e6b8dd 100644 --- a/packages/effect/src/ai/McpServer.ts +++ b/packages/effect/src/ai/McpServer.ts @@ -1534,11 +1534,16 @@ const mcpStdioSerialization = ( * remain valid. The surrounding HTTP server remains responsible for binding * to an appropriate interface and installing authentication. * + * Client session termination is opt-in. With `allowSessionTermination`, a + * DELETE carrying an `Mcp-Session-Id` ends that session with `204` (`404` for + * an unknown session, `400` without the header), and later requests with the + * id get `404`. Without it, DELETE returns `405` like other unsupported + * methods, which the spec allows. + * * `layerHttp` always implements the single-endpoint Streamable HTTP topology. * Using `v2024_11_05` here is a custom compatibility transport for that * revision's schema. It does not implement the historical two-endpoint - * HTTP+SSE transport, GET SSE, event resumption, session expiry, or client - * session termination. + * HTTP+SSE transport, GET SSE, event resumption, or session expiry. * * @see {@link layerStdio} for exposing the server over stdio * @see {@link layer} for the base MCP server layer without a transport protocol @@ -1558,6 +1563,7 @@ export const layerHttp = (options: { readonly protocols: Arr.NonEmptyReadonlyArray readonly extensions?: ServerExtensions | undefined readonly allowedOrigins?: ReadonlyArray | undefined + readonly allowSessionTermination?: boolean | undefined }): Layer.Layer => { const runtime = McpRuntime.layer(options.protocols) const methodNotAllowedResponse = HttpServerResponse.empty({ @@ -1572,8 +1578,10 @@ export const layerHttp = (options: { HttpRouter.add("GET", options.path, methodNotAllowed), HttpRouter.add("PUT", options.path, methodNotAllowed), HttpRouter.add("PATCH", options.path, methodNotAllowed), - HttpRouter.add("DELETE", options.path, methodNotAllowed), - HttpRouter.add("OPTIONS", options.path, methodNotAllowed) + HttpRouter.add("OPTIONS", options.path, methodNotAllowed), + options.allowSessionTermination === true + ? Layer.empty + : HttpRouter.add("DELETE", options.path, methodNotAllowed) ) return Layer.merge(layerWithRuntime(options, "http"), routes).pipe( Layer.provide(layerMcpProtocolHttp(options)), @@ -1585,6 +1593,7 @@ export const layerHttp = (options: { const layerMcpProtocolHttp = (options: { readonly path: HttpRouter.PathInput readonly allowedOrigins?: ReadonlyArray | undefined + readonly allowSessionTermination?: boolean | undefined }): Layer.Layer< RpcServer.Protocol, never, @@ -1628,6 +1637,21 @@ const layerMcpProtocolHttp = (options: { : response }) }) + if (options.allowSessionTermination !== true) { + return protocol + } + // A client that no longer needs its session terminates it with DELETE. Later + // requests with that session id then get 404, which tells the client to + // initialize a new one. + yield* router.add("DELETE", options.path, (request) => { + if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { + return Effect.succeed(HttpServerResponse.empty({ status: 403 })) + } + const sessionId = request.headers[MCP_SESSION_ID_HEADER] + return Effect.succeed(HttpServerResponse.empty({ + status: sessionId === undefined ? 400 : runtime.terminateSession(sessionId) ? 204 : 404 + })) + }) return protocol })) diff --git a/packages/effect/src/ai/internal/mcpRuntime.ts b/packages/effect/src/ai/internal/mcpRuntime.ts index b388d178053..f148920b976 100644 --- a/packages/effect/src/ai/internal/mcpRuntime.ts +++ b/packages/effect/src/ai/internal/mcpRuntime.ts @@ -175,6 +175,8 @@ export interface ServerRuntimeShape { fallback: LogLevel.LogLevel ) => LogLevel.LogLevel readonly disconnect: (clientId: number) => void + /** Ends an HTTP session; returns whether it existed. */ + readonly terminateSession: (sessionId: string) => boolean readonly deliveryClientIds: () => Iterable readonly canDeliver: ( clientId: number, @@ -464,6 +466,7 @@ export const make = Effect.fnUntraced(function*( }, effectLogLevel: (clientId, headers, fallback) => stateful?.effectLogLevel(clientId, headers, fallback) ?? fallback, disconnect: (clientId) => stateful?.disconnect(clientId), + terminateSession: (sessionId) => stateful?.terminateSession(sessionId) ?? false, deliveryClientIds: () => stateful?.initializedClientIds() ?? [], canDeliver: (clientId, headers, notification, fallback) => stateful?.canDeliver(clientId, headers, notification, fallback) ?? true, diff --git a/packages/effect/src/ai/internal/mcpStatefulRuntime.ts b/packages/effect/src/ai/internal/mcpStatefulRuntime.ts index 19c41ab07b1..ea19493b210 100644 --- a/packages/effect/src/ai/internal/mcpStatefulRuntime.ts +++ b/packages/effect/src/ai/internal/mcpStatefulRuntime.ts @@ -47,6 +47,8 @@ export interface StatefulRuntime { readonly registerConnection: (clientId: number, registration: Registration) => Binding readonly resolve: (clientId: number, headers: Headers.Headers) => Binding | undefined readonly resolveSessionId: (sessionId: string) => Binding | undefined + /** Ends an HTTP session; returns whether it existed. */ + readonly terminateSession: (sessionId: string) => boolean readonly setLogLevel: ( level: PublicMcpSchema.LoggingLevel, clientId: number, @@ -123,6 +125,7 @@ export const make = (): StatefulRuntime => { }, resolve: resolveSession, resolveSessionId: (sessionId) => bySessionId.get(sessionId), + terminateSession: (sessionId) => bySessionId.delete(sessionId), setLogLevel: (level, clientId, headers) => Effect.sync(() => { const session = resolveSession(clientId, headers) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 4f235c71143..45aa6ef59e3 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -38,7 +38,7 @@ import * as Stdio from "effect/Stdio" import * as Stream from "effect/Stream" import * as TestClock from "effect/testing/TestClock" import { collectGarbage } from "../../utils/gc.ts" -import { initializeHttpSession, makeHttpHarness } from "./TestUtils/McpHttpHarness.ts" +import { initializeHttpSession, makeHttpHarness, MCP_ENDPOINT } from "./TestUtils/McpHttpHarness.ts" import { makeMcpSseReader, readMcpHttpResponse } from "./TestUtils/McpHttpResponse.ts" import { makeServerLayer } from "./TestUtils/McpServerLayer.ts" import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" @@ -2180,6 +2180,25 @@ describe("McpServer", () => { yield* client.ping({}) })) + it.effect("terminates an HTTP session on DELETE when allowSessionTermination is set", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2025_11_25], + allowSessionTermination: true + })) + const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25) + const remove = (headers: HeadersInit) => + Effect.promise(() => harness.handler(new Request(MCP_ENDPOINT, { method: "DELETE", headers }))) + + strictEqual((yield* harness.post({ jsonrpc: "2.0", id: 2, method: "ping" }, headers)).status, 200) + strictEqual((yield* remove(headers)).status, 204) + // The session is gone: requests carrying its id get 404, so the client re-initializes. + strictEqual((yield* harness.post({ jsonrpc: "2.0", id: 3, method: "ping" }, headers)).status, 404) + strictEqual((yield* remove(headers)).status, 404) + strictEqual((yield* remove({})).status, 400) + })) + it.effect("returns an empty 202 for notifications and responses and remains successful for request POSTs", () => Effect.gen(function*() { const { client, httpClient } = yield* makeRouterTestClient(HttpRouter.cors()) diff --git a/packages/effect/test/ai/McpServer/TestUtils/McpServerLayer.ts b/packages/effect/test/ai/McpServer/TestUtils/McpServerLayer.ts index 8995b0a51e5..625d38b68d3 100644 --- a/packages/effect/test/ai/McpServer/TestUtils/McpServerLayer.ts +++ b/packages/effect/test/ai/McpServer/TestUtils/McpServerLayer.ts @@ -20,6 +20,7 @@ export const makeServerLayer = (options: { | undefined readonly extensions?: Readonly> | undefined readonly allowedOrigins?: ReadonlyArray | undefined + readonly allowSessionTermination?: boolean | undefined }) => McpServer.layerHttp({ name: options.name, @@ -28,7 +29,8 @@ export const makeServerLayer = (options: { path: "/mcp", protocols: options.protocols ?? [McpProtocol.v2025_06_18], allowedOrigins: ["https://allowed.example"], - extensions: options.extensions + extensions: options.extensions, + allowSessionTermination: options.allowSessionTermination }).pipe( Layer.provideMerge(Layer.succeed( References.CurrentLoggers, From e76d19dc04788ad794e10e9f29c5208f9c062f96 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:11:33 +0000 Subject: [PATCH 2/8] test(ai): focus HTTP session termination coverage --- .../test/ai/McpServer/McpServer.test.ts | 33 ++++++++++++++----- .../ai/McpServer/TestUtils/McpHttpHarness.ts | 3 ++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 45aa6ef59e3..fa51359e922 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -38,7 +38,7 @@ import * as Stdio from "effect/Stdio" import * as Stream from "effect/Stream" import * as TestClock from "effect/testing/TestClock" import { collectGarbage } from "../../utils/gc.ts" -import { initializeHttpSession, makeHttpHarness, MCP_ENDPOINT } from "./TestUtils/McpHttpHarness.ts" +import { initializeHttpSession, makeHttpHarness } from "./TestUtils/McpHttpHarness.ts" import { makeMcpSseReader, readMcpHttpResponse } from "./TestUtils/McpHttpResponse.ts" import { makeServerLayer } from "./TestUtils/McpServerLayer.ts" import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" @@ -2188,15 +2188,32 @@ describe("McpServer", () => { allowSessionTermination: true })) const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25) - const remove = (headers: HeadersInit) => - Effect.promise(() => harness.handler(new Request(MCP_ENDPOINT, { method: "DELETE", headers }))) + strictEqual((yield* harness.delete(headers)).status, 204) + strictEqual((yield* harness.post({ jsonrpc: "2.0", id: 3, method: "ping" }, headers)).status, 404) + strictEqual((yield* harness.delete(headers)).status, 404) + })) + + it.effect("rejects DELETE with a missing or unknown session id when termination is enabled", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "SessionDelete", + allowSessionTermination: true + })) + strictEqual((yield* harness.delete()).status, 400) + strictEqual((yield* harness.delete({ "Mcp-Session-Id": "unknown" })).status, 404) + })) + it.effect("rejects disallowed DELETE Origins without terminating the session", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2025_11_25], + allowSessionTermination: true + })) + const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25) + strictEqual((yield* harness.delete({ ...headers, origin: "https://blocked.example" })).status, 403) strictEqual((yield* harness.post({ jsonrpc: "2.0", id: 2, method: "ping" }, headers)).status, 200) - strictEqual((yield* remove(headers)).status, 204) - // The session is gone: requests carrying its id get 404, so the client re-initializes. - strictEqual((yield* harness.post({ jsonrpc: "2.0", id: 3, method: "ping" }, headers)).status, 404) - strictEqual((yield* remove(headers)).status, 404) - strictEqual((yield* remove({})).status, 400) + strictEqual((yield* harness.delete({ ...headers, origin: "https://allowed.example" })).status, 204) })) it.effect("returns an empty 202 for notifications and responses and remains successful for request POSTs", () => diff --git a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts index e08c8e09232..97d31699efc 100644 --- a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts +++ b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts @@ -52,12 +52,15 @@ export const makeHttpHarness = Effect.fnUntraced(function*( ) const post = (body: unknown, headers?: HeadersInit) => postText(JSON.stringify(body), headers) + const deleteSession = (headers?: HeadersInit) => + Effect.promise(() => handler(new Request(MCP_ENDPOINT, { method: "DELETE", headers: headers ?? {} }))) return { handler, fetch, post, postText, + delete: deleteSession, responses } as const }) From fe99061e420b766df9aea12f15603b43a57f54cd Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:20:23 +0000 Subject: [PATCH 3/8] fix(ai): validate DELETE like POST and stop a terminated session's requests - Route every MCP endpoint method from layerMcpProtocolHttp - Keep DELETE at 405 when no stateful protocol is configured - Check MCP-Protocol-Version on DELETE with the same session rules as POST - Interrupt in-flight requests of a terminated session and answer them with 404 - Advertise DELETE in Allow when termination is enabled --- packages/effect/src/ai/McpServer.ts | 112 +++++++++++------- packages/effect/src/ai/internal/mcpRuntime.ts | 80 +++++++++---- 2 files changed, 124 insertions(+), 68 deletions(-) diff --git a/packages/effect/src/ai/McpServer.ts b/packages/effect/src/ai/McpServer.ts index 04cd1e6b8dd..91296648212 100644 --- a/packages/effect/src/ai/McpServer.ts +++ b/packages/effect/src/ai/McpServer.ts @@ -654,6 +654,7 @@ const cancelledResponses = new WeakMap() const requestKey = (requestId: string | number): string => `${typeof requestId}:${requestId}` interface ActiveRequest { + readonly requestId: RpcMessage.RequestId readonly prepared: McpRuntime.PreparedRequest readonly cancelled: boolean } @@ -805,6 +806,7 @@ const runWithRuntime = Effect.fnUntraced(function*( payload: { requestId, reason } }) }) + let writeFromClient!: (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect const handlers = yield* runtime.installHandlers({ core: internalState.get(server)!.core, subscribeServerNotifications: PubSub.subscribe(serverNotifications), @@ -976,8 +978,9 @@ const runWithRuntime = Effect.fnUntraced(function*( } return protocol.send(clientId, response) }, - run: (f) => - protocol.run((clientId, request_) => { + run: (f) => { + writeFromClient = f + return protocol.run((clientId, request_) => { const fiber = Fiber.getCurrent()! const request = request_ as unknown as | RpcMessage.FromServerEncoded @@ -1045,11 +1048,14 @@ const runWithRuntime = Effect.fnUntraced(function*( if (httpRequest !== undefined && session !== undefined) { appendPreResponseHandlerUnsafe(httpRequest, (_, res) => Effect.succeed( - HttpServerResponse.setHeader( - res, - MCP_PROTOCOL_VERSION_HEADER, - session.protocol.protocolVersion - ) + // The session was terminated while this request was in flight. + runtime.resolveRequest(clientId, headers) === undefined + ? HttpServerResponse.empty({ status: 404 }) + : HttpServerResponse.setHeader( + res, + MCP_PROTOCOL_VERSION_HEADER, + session.protocol.protocolVersion + ) )) } const routedRequest = runtime.routeClientRequest(selectedProtocol, request) @@ -1130,7 +1136,11 @@ const runWithRuntime = Effect.fnUntraced(function*( } if (request.isNotification !== true) { const requests = activeRequests.get(clientId) ?? new Map() - requests.set(requestKey(request.id), { prepared, cancelled: false }) + requests.set(requestKey(request.id), { + requestId: RpcMessage.RequestId(request.id), + prepared, + cancelled: false + }) activeRequests.set(clientId, requests) } const handled = f(clientId, routedRequest) @@ -1205,7 +1215,22 @@ const runWithRuntime = Effect.fnUntraced(function*( } } }) + } }) + if (isHttp) { + // Replies to a terminated session's server requests would be rejected, so stop its in-flight requests. + yield* runtime.onSessionTerminated((binding) => + Effect.forEach(Array.from(activeRequests), ([clientId, requests]) => + Effect.forEach( + Array.from(requests.values()), + ({ prepared, requestId }) => + prepared.binding === binding && cancelRequest(clientId, requestId) + ? writeFromClient(clientId, { _tag: "Interrupt", requestId }) + : Effect.void, + { discard: true } + ), { discard: true }) + ) + } const { notificationDelivery, notifications } = internalState.get(server)! yield* Effect.acquireRelease( @@ -1535,10 +1560,13 @@ const mcpStdioSerialization = ( * to an appropriate interface and installing authentication. * * Client session termination is opt-in. With `allowSessionTermination`, a - * DELETE carrying an `Mcp-Session-Id` ends that session with `204` (`404` for - * an unknown session, `400` without the header), and later requests with the - * id get `404`. Without it, DELETE returns `405` like other unsupported - * methods, which the spec allows. + * DELETE carrying an `Mcp-Session-Id` ends that session with `204` and + * interrupts its in-flight requests; later requests with the id get `404`. + * DELETE returns `404` for an unknown session, `400` without the header, and + * `400` for an `MCP-Protocol-Version` that POST would also reject. Without the + * option, or when only sessionless revisions such as `v2026_07_28` are + * configured, DELETE returns `405`. Any caller holding a session id can end + * that session, so authenticate requests in the surrounding router. * * `layerHttp` always implements the single-endpoint Streamable HTTP topology. * Using `v2024_11_05` here is a custom compatibility transport for that @@ -1566,24 +1594,7 @@ export const layerHttp = (options: { readonly allowSessionTermination?: boolean | undefined }): Layer.Layer => { const runtime = McpRuntime.layer(options.protocols) - const methodNotAllowedResponse = HttpServerResponse.empty({ - status: 405, - headers: { allow: "POST" } - }) - const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => - isAllowedMcpOrigin(request, options.allowedOrigins) - ? Effect.succeed(methodNotAllowedResponse) - : Effect.succeed(HttpServerResponse.empty({ status: 403 })) - const routes = Layer.mergeAll( - HttpRouter.add("GET", options.path, methodNotAllowed), - HttpRouter.add("PUT", options.path, methodNotAllowed), - HttpRouter.add("PATCH", options.path, methodNotAllowed), - HttpRouter.add("OPTIONS", options.path, methodNotAllowed), - options.allowSessionTermination === true - ? Layer.empty - : HttpRouter.add("DELETE", options.path, methodNotAllowed) - ) - return Layer.merge(layerWithRuntime(options, "http"), routes).pipe( + return layerWithRuntime(options, "http").pipe( Layer.provide(layerMcpProtocolHttp(options)), Layer.provide(runtime), Layer.provide(RpcSerialization.layerJsonRpc()) @@ -1605,6 +1616,32 @@ const layerMcpProtocolHttp = (options: { Effect.provideService(RpcSerialization.RpcSerialization, mcpHttpSerialization) ) const router = yield* HttpRouter.HttpRouter + // Revisions from 2026-07-28 have no sessions, so DELETE stays unsupported without a stateful protocol. + const allowSessionTermination = options.allowSessionTermination === true && + runtime.protocols.some((protocol) => protocol.runtime._tag === "Stateful") + const methodNotAllowedResponse = HttpServerResponse.empty({ + status: 405, + headers: { allow: allowSessionTermination ? "POST, DELETE" : "POST" } + }) + const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => + Effect.succeed( + isAllowedMcpOrigin(request, options.allowedOrigins) + ? methodNotAllowedResponse + : HttpServerResponse.empty({ status: 403 }) + ) + for (const method of ["GET", "PUT", "PATCH", "OPTIONS"] as const) { + yield* router.add(method, options.path, methodNotAllowed) + } + yield* router.add( + "DELETE", + options.path, + allowSessionTermination + ? (request) => + isAllowedMcpOrigin(request, options.allowedOrigins) + ? runtime.terminateHttpSession(request.headers) + : Effect.succeed(HttpServerResponse.empty({ status: 403 })) + : methodNotAllowed + ) yield* router.add("POST", options.path, (request) => { if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { return Effect.succeed(HttpServerResponse.empty({ status: 403 })) @@ -1637,21 +1674,6 @@ const layerMcpProtocolHttp = (options: { : response }) }) - if (options.allowSessionTermination !== true) { - return protocol - } - // A client that no longer needs its session terminates it with DELETE. Later - // requests with that session id then get 404, which tells the client to - // initialize a new one. - yield* router.add("DELETE", options.path, (request) => { - if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { - return Effect.succeed(HttpServerResponse.empty({ status: 403 })) - } - const sessionId = request.headers[MCP_SESSION_ID_HEADER] - return Effect.succeed(HttpServerResponse.empty({ - status: sessionId === undefined ? 400 : runtime.terminateSession(sessionId) ? 204 : 404 - })) - }) return protocol })) diff --git a/packages/effect/src/ai/internal/mcpRuntime.ts b/packages/effect/src/ai/internal/mcpRuntime.ts index f148920b976..2f520b5f66b 100644 --- a/packages/effect/src/ai/internal/mcpRuntime.ts +++ b/packages/effect/src/ai/internal/mcpRuntime.ts @@ -17,6 +17,7 @@ import * as Predicate from "../../Predicate.ts" import * as Result from "../../Result.ts" import * as RpcGroup from "../../rpc/RpcGroup.ts" import type * as RpcMessage from "../../rpc/RpcMessage.ts" +import type * as Scope from "../../Scope.ts" import type * as PublicMcpProtocol from "../McpProtocol.ts" import * as PublicMcpSchema from "../McpSchema.ts" import type * as McpCore from "./mcpCore.ts" @@ -175,8 +176,10 @@ export interface ServerRuntimeShape { fallback: LogLevel.LogLevel ) => LogLevel.LogLevel readonly disconnect: (clientId: number) => void - /** Ends an HTTP session; returns whether it existed. */ - readonly terminateSession: (sessionId: string) => boolean + readonly terminateHttpSession: (headers: Headers.Headers) => Effect.Effect + readonly onSessionTerminated: ( + listener: (binding: RequestBinding) => Effect.Effect + ) => Effect.Effect readonly deliveryClientIds: () => Iterable readonly canDeliver: ( clientId: number, @@ -230,9 +233,32 @@ export const make = Effect.fnUntraced(function*( statelessProtocol = protocol } const registry = yield* McpProtocolRegistry.make(protocols) - const selectHttpProtocol = (headers: Headers.Headers, input: unknown): HttpProtocolSelection => { + const sessionTerminationListeners = new Set<(binding: RequestBinding) => Effect.Effect>() + const selectHttpSession = (headers: Headers.Headers, isInitialize: boolean): HttpProtocolSelection => { const protocolVersion = headers[MCP_PROTOCOL_VERSION_HEADER] const sessionId = headers[MCP_SESSION_ID_HEADER] + const binding = sessionId === undefined ? undefined : stateful?.resolveSessionId(sessionId) + if (sessionId !== undefined && binding === undefined) { + return { _tag: "Rejected", status: 404 } + } + if ( + !isInitialize && + protocolVersion !== undefined && + !registry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion) + ) { + return { _tag: "Rejected", status: 400 } + } + if ( + !isInitialize && + binding?.protocol.runtime.transport.http.requiresVersionHeader === true && + protocolVersion !== binding.protocol.protocolVersion + ) { + return { _tag: "Rejected", status: 400 } + } + return { _tag: "Accepted", binding, protocol: binding?.protocol } + } + const selectHttpProtocol = (headers: Headers.Headers, input: unknown): HttpProtocolSelection => { + const protocolVersion = headers[MCP_PROTOCOL_VERSION_HEADER] const inputRecord = asRecord(input) const metadata = asRecord(asRecord(inputRecord?.params)?._meta) const claim = protocolVersionClaim(metadata) @@ -301,25 +327,7 @@ export const make = Effect.fnUntraced(function*( } return { _tag: "Accepted", binding: undefined, protocol: statelessProtocol } } - const binding = sessionId === undefined ? undefined : stateful?.resolveSessionId(sessionId) - if (sessionId !== undefined && binding === undefined) { - return { _tag: "Rejected", status: 404 } - } - if ( - !isInitialize && - protocolVersion !== undefined && - !registry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion) - ) { - return { _tag: "Rejected", status: 400 } - } - if ( - !isInitialize && - binding?.protocol.runtime.transport.http.requiresVersionHeader === true && - protocolVersion !== binding.protocol.protocolVersion - ) { - return { _tag: "Rejected", status: 400 } - } - return { _tag: "Accepted", binding, protocol: binding?.protocol } + return selectHttpSession(headers, isInitialize) } return ServerRuntime.of({ protocols: registry.protocols, @@ -466,7 +474,33 @@ export const make = Effect.fnUntraced(function*( }, effectLogLevel: (clientId, headers, fallback) => stateful?.effectLogLevel(clientId, headers, fallback) ?? fallback, disconnect: (clientId) => stateful?.disconnect(clientId), - terminateSession: (sessionId) => stateful?.terminateSession(sessionId) ?? false, + terminateHttpSession: (headers) => + Effect.suspend(() => { + const sessionId = headers[MCP_SESSION_ID_HEADER] + if (sessionId === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) + } + const selection = selectHttpSession(headers, false) + if (selection._tag === "Rejected") { + return Effect.succeed(HttpServerResponse.empty({ status: selection.status })) + } + const binding = selection.binding! + stateful!.terminateSession(sessionId) + return Effect.as( + Effect.forEach(sessionTerminationListeners, (listener) => listener(binding), { discard: true }), + HttpServerResponse.empty({ status: 204 }) + ) + }), + onSessionTerminated: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + sessionTerminationListeners.add(listener) + }), + () => + Effect.sync(() => { + sessionTerminationListeners.delete(listener) + }) + ), deliveryClientIds: () => stateful?.initializedClientIds() ?? [], canDeliver: (clientId, headers, notification, fallback) => stateful?.canDeliver(clientId, headers, notification, fallback) ?? true, From 8af0500c798f7bce45bfc5b5d65dbb6a9fb534fc Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:26:35 +0000 Subject: [PATCH 4/8] test(ai): cover HTTP session termination lifecycle and protocol guards --- .../test/ai/McpServer/McpServer.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index fa51359e922..35f53c79c1c 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -2216,6 +2216,155 @@ describe("McpServer", () => { strictEqual((yield* harness.delete({ ...headers, origin: "https://allowed.example" })).status, 204) })) + it.effect("rejects unsupported or mismatched DELETE protocol versions without terminating the session", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2025_06_18, McpProtocol.v2025_11_25], + allowSessionTermination: true + })) + const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25) + for (const version of ["9999-01-01", "2025-06-18"]) { + strictEqual((yield* harness.delete({ ...headers, "Mcp-Protocol-Version": version })).status, 400) + const ping = yield* harness.post({ jsonrpc: "2.0", id: 2, method: "ping" }, headers) + strictEqual(ping.status, 200) + assert.deepStrictEqual(yield* readMcpHttpResponse(ping), { jsonrpc: "2.0", id: 2, result: {} }) + } + strictEqual((yield* harness.delete(headers)).status, 204) + })) + + it.effect("refuses DELETE for a stateless-only server even when termination is enabled", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "StatelessDelete", + protocols: [McpProtocol.v2026_07_28], + allowSessionTermination: true + })) + const response = yield* harness.delete() + strictEqual(response.status, 405) + strictEqual(response.headers.get("Allow"), "POST") + })) + + it.effect("advertises DELETE on unsupported methods when stateful termination is enabled", () => + Effect.gen(function*() { + const harness = yield* makeHttpHarness(makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2026_07_28, McpProtocol.v2025_11_25], + allowSessionTermination: true + })) + for (const method of ["GET", "PUT", "PATCH", "HEAD"] as const) { + const response = yield* Effect.promise(() => harness.handler(new Request("http://localhost/mcp", { method }))) + strictEqual(response.status, 405) + strictEqual(response.headers.get("Allow"), "POST, DELETE") + } + })) + + it.effect("interrupts an in-flight tool on DELETE and returns 404 for its pending POST", () => + Effect.gen(function*() { + const started = yield* Deferred.make() + const interrupted = yield* Deferred.make() + const registration = Layer.effectDiscard(Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool({ + tool: new McpSchema.Tool({ name: "Blocked", inputSchema: { type: "object" } }), + annotations: Context.empty(), + handle: () => + Effect.yieldNow.pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)) + ) + }) + })) + const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge( + makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2025_11_25], + allowSessionTermination: true + }) + ))) + const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25) + const pending = yield* harness.post({ + jsonrpc: "2.0", + id: "blocked-tool", + method: "tools/call", + params: { name: "Blocked", arguments: {} } + }, headers).pipe(Effect.forkScoped) + yield* Deferred.await(started) + strictEqual((yield* harness.delete(headers)).status, 204) + yield* Deferred.await(interrupted) + strictEqual((yield* Fiber.join(pending)).status, 404) + })) + + it.effect("closes an already-started elicitation stream on DELETE", () => + Effect.gen(function*() { + const interrupted = yield* Deferred.make() + const registration = Layer.effectDiscard(Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool({ + tool: new McpSchema.Tool({ name: "Authorize", inputSchema: { type: "object" } }), + annotations: Context.empty(), + handle: () => + Effect.gen(function*() { + const client = yield* Effect.serviceOption(McpSchema.McpServerClient).pipe( + Effect.flatMap(Effect.fromOption) + ) + const reverseClient = yield* client.getClient + yield* reverseClient.elicit( + Schema.decodeUnknownSync(McpSchema.Elicit.payloadSchema)({ + mode: "url", + message: "Authorize access", + url: "https://example.com/authorize", + elicitationId: "authorization-1" + }) + ) + return new McpSchema.CallToolResult({ content: [] }) + }).pipe( + Effect.scoped, + Effect.orDie, + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)) + ) + }) + })) + const harness = yield* makeHttpHarness(registration.pipe(Layer.provideMerge( + makeServerLayer({ + name: "SessionDelete", + protocols: [McpProtocol.v2025_11_25], + allowSessionTermination: true + }) + ))) + const initialized = yield* harness.post({ + jsonrpc: "2.0", + id: "initialize", + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: { elicitation: { url: {} } }, + clientInfo: { name: "authorization-client", version: "1.0.0" } + } + }) + yield* readMcpHttpResponse(initialized) + const sessionId = initialized.headers.get("Mcp-Session-Id") + assert.isNotNull(sessionId) + const headers = { "Mcp-Session-Id": sessionId, "Mcp-Protocol-Version": "2025-11-25" } + yield* harness.post({ jsonrpc: "2.0", method: "notifications/initialized" }, headers) + const response = yield* harness.post({ + jsonrpc: "2.0", + id: "authorize-tool", + method: "tools/call", + params: { name: "Authorize", arguments: {} } + }, headers) + const stream = makeMcpSseReader(response) + yield* Effect.addFinalizer(() => stream.cancel) + const elicitation = yield* stream.take() + strictEqual(elicitation.method, "elicitation/create") + assert.isDefined(elicitation.id) + strictEqual((yield* harness.delete(headers)).status, 204) + yield* Deferred.await(interrupted) + const remaining = yield* stream.drain() + assert.isFalse(remaining.some((message) => message.id === "authorize-tool")) + })) + it.effect("returns an empty 202 for notifications and responses and remains successful for request POSTs", () => Effect.gen(function*() { const { client, httpClient } = yield* makeRouterTestClient(HttpRouter.cors()) From d7fcc8631f146cee0859e9a5cbd27ce5ca508b65 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:33:41 +0000 Subject: [PATCH 5/8] test(ai): cover DELETE racing a tool that signals before yielding --- packages/effect/test/ai/McpServer/McpServer.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 35f53c79c1c..6950292f36f 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -2269,8 +2269,7 @@ describe("McpServer", () => { tool: new McpSchema.Tool({ name: "Blocked", inputSchema: { type: "object" } }), annotations: Context.empty(), handle: () => - Effect.yieldNow.pipe( - Effect.andThen(Deferred.succeed(started, undefined)), + Deferred.succeed(started, undefined).pipe( Effect.andThen(Effect.never), Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)) ) From 6c84812e5be094b03086bcc767cd2ab4acd75b1b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:36:00 +0000 Subject: [PATCH 6/8] fix(rpc): register request fibers before their handlers run --- packages/effect/src/rpc/RpcServer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/rpc/RpcServer.ts b/packages/effect/src/rpc/RpcServer.ts index a6d06f709f3..1e5d8154c81 100644 --- a/packages/effect/src/rpc/RpcServer.ts +++ b/packages/effect/src/rpc/RpcServer.ts @@ -359,11 +359,14 @@ export const makeNoSerialization: ( ) const fiber = trackFiber( runFork( - effect, + // Register before the handler runs, so an interrupt sent while it runs synchronously can find it. + Effect.withFiber((fiber) => { + client.fibers.set(request.id, fiber) + return effect + }), isUninterruptible ? { uninterruptible: true } : undefined ) ) - client.fibers.set(request.id, fiber) fiber.addObserver(function onExit(exit: Exit.Exit): void { if (deferred) { const fiber = trackFiber(runFork(Effect.onExit(Deferred.await(deferred), (exit) => From d578c82cebedb3b56b1ee3cff7130b11694f4032 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 20:39:31 +0000 Subject: [PATCH 7/8] docs: trim session termination comments and summarize cancellation fix --- .changeset/mcp-http-session-termination.md | 4 +++- packages/effect/src/ai/McpServer.ts | 4 +--- packages/effect/src/ai/internal/mcpStatefulRuntime.ts | 1 - packages/effect/src/rpc/RpcServer.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.changeset/mcp-http-session-termination.md b/.changeset/mcp-http-session-termination.md index f27d927611b..46bd7c70b9a 100644 --- a/.changeset/mcp-http-session-termination.md +++ b/.changeset/mcp-http-session-termination.md @@ -2,4 +2,6 @@ "effect": patch --- -Add `allowSessionTermination` to `McpServer.layerHttp`. When set, a DELETE with an `Mcp-Session-Id` ends that session (`204`), and later requests with the id get `404` so the client re-initializes. Without it, DELETE still returns `405`. +Add opt-in `allowSessionTermination` to `McpServer.layerHttp`. DELETE ends the session and interrupts its active requests; later requests with that session id return `404`. + +Fix an RPC cancellation race by registering request fibers before their handlers run. diff --git a/packages/effect/src/ai/McpServer.ts b/packages/effect/src/ai/McpServer.ts index 91296648212..8c21d284f67 100644 --- a/packages/effect/src/ai/McpServer.ts +++ b/packages/effect/src/ai/McpServer.ts @@ -1048,7 +1048,6 @@ const runWithRuntime = Effect.fnUntraced(function*( if (httpRequest !== undefined && session !== undefined) { appendPreResponseHandlerUnsafe(httpRequest, (_, res) => Effect.succeed( - // The session was terminated while this request was in flight. runtime.resolveRequest(clientId, headers) === undefined ? HttpServerResponse.empty({ status: 404 }) : HttpServerResponse.setHeader( @@ -1218,7 +1217,7 @@ const runWithRuntime = Effect.fnUntraced(function*( } }) if (isHttp) { - // Replies to a terminated session's server requests would be rejected, so stop its in-flight requests. + // Stop requests that can no longer receive client replies after termination. yield* runtime.onSessionTerminated((binding) => Effect.forEach(Array.from(activeRequests), ([clientId, requests]) => Effect.forEach( @@ -1616,7 +1615,6 @@ const layerMcpProtocolHttp = (options: { Effect.provideService(RpcSerialization.RpcSerialization, mcpHttpSerialization) ) const router = yield* HttpRouter.HttpRouter - // Revisions from 2026-07-28 have no sessions, so DELETE stays unsupported without a stateful protocol. const allowSessionTermination = options.allowSessionTermination === true && runtime.protocols.some((protocol) => protocol.runtime._tag === "Stateful") const methodNotAllowedResponse = HttpServerResponse.empty({ diff --git a/packages/effect/src/ai/internal/mcpStatefulRuntime.ts b/packages/effect/src/ai/internal/mcpStatefulRuntime.ts index ea19493b210..bcede31008d 100644 --- a/packages/effect/src/ai/internal/mcpStatefulRuntime.ts +++ b/packages/effect/src/ai/internal/mcpStatefulRuntime.ts @@ -47,7 +47,6 @@ export interface StatefulRuntime { readonly registerConnection: (clientId: number, registration: Registration) => Binding readonly resolve: (clientId: number, headers: Headers.Headers) => Binding | undefined readonly resolveSessionId: (sessionId: string) => Binding | undefined - /** Ends an HTTP session; returns whether it existed. */ readonly terminateSession: (sessionId: string) => boolean readonly setLogLevel: ( level: PublicMcpSchema.LoggingLevel, diff --git a/packages/effect/src/rpc/RpcServer.ts b/packages/effect/src/rpc/RpcServer.ts index 1e5d8154c81..c5e260d81c3 100644 --- a/packages/effect/src/rpc/RpcServer.ts +++ b/packages/effect/src/rpc/RpcServer.ts @@ -359,7 +359,7 @@ export const makeNoSerialization: ( ) const fiber = trackFiber( runFork( - // Register before the handler runs, so an interrupt sent while it runs synchronously can find it. + // Register before the handler runs to catch synchronous cancellation. Effect.withFiber((fiber) => { client.fibers.set(request.id, fiber) return effect From c13617d4b66b60aeec3805d585260ab94c2cfa8e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 5 Oct 2026 21:14:46 +0000 Subject: [PATCH 8/8] refactor(ai): simplify HTTP session termination routing and tests --- packages/effect/src/ai/McpServer.ts | 59 +++++++++---------- .../test/ai/McpServer/McpServer.test.ts | 16 +---- .../ai/McpServer/TestUtils/McpHttpHarness.ts | 5 +- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/packages/effect/src/ai/McpServer.ts b/packages/effect/src/ai/McpServer.ts index 8c21d284f67..1b79c680889 100644 --- a/packages/effect/src/ai/McpServer.ts +++ b/packages/effect/src/ai/McpServer.ts @@ -1218,17 +1218,17 @@ const runWithRuntime = Effect.fnUntraced(function*( }) if (isHttp) { // Stop requests that can no longer receive client replies after termination. - yield* runtime.onSessionTerminated((binding) => - Effect.forEach(Array.from(activeRequests), ([clientId, requests]) => - Effect.forEach( - Array.from(requests.values()), - ({ prepared, requestId }) => - prepared.binding === binding && cancelRequest(clientId, requestId) - ? writeFromClient(clientId, { _tag: "Interrupt", requestId }) - : Effect.void, - { discard: true } - ), { discard: true }) - ) + yield* runtime.onSessionTerminated((binding) => { + const interrupts: Array> = [] + for (const [clientId, requests] of activeRequests) { + for (const { prepared, requestId } of requests.values()) { + if (prepared.binding === binding && cancelRequest(clientId, requestId)) { + interrupts.push(writeFromClient(clientId, { _tag: "Interrupt", requestId })) + } + } + } + return Effect.all(interrupts, { discard: true }) + }) } const { notificationDelivery, notifications } = internalState.get(server)! @@ -1558,14 +1558,13 @@ const mcpStdioSerialization = ( * remain valid. The surrounding HTTP server remains responsible for binding * to an appropriate interface and installing authentication. * - * Client session termination is opt-in. With `allowSessionTermination`, a - * DELETE carrying an `Mcp-Session-Id` ends that session with `204` and - * interrupts its in-flight requests; later requests with the id get `404`. - * DELETE returns `404` for an unknown session, `400` without the header, and - * `400` for an `MCP-Protocol-Version` that POST would also reject. Without the - * option, or when only sessionless revisions such as `v2026_07_28` are - * configured, DELETE returns `405`. Any caller holding a session id can end - * that session, so authenticate requests in the surrounding router. + * With `allowSessionTermination`, a DELETE carrying `Mcp-Session-Id` ends that + * session with `204` and interrupts its in-flight requests; later requests with + * that id get `404`. DELETE validates the session and `MCP-Protocol-Version` + * headers like POST. Without the option, or when only sessionless revisions + * such as `v2026_07_28` are configured, DELETE returns `405`. Any caller + * holding a session id can end that session, so authenticate requests in the + * surrounding router. * * `layerHttp` always implements the single-endpoint Streamable HTTP topology. * Using `v2024_11_05` here is a custom compatibility transport for that @@ -1617,16 +1616,17 @@ const layerMcpProtocolHttp = (options: { const router = yield* HttpRouter.HttpRouter const allowSessionTermination = options.allowSessionTermination === true && runtime.protocols.some((protocol) => protocol.runtime._tag === "Stateful") - const methodNotAllowedResponse = HttpServerResponse.empty({ + const forbidden = Effect.succeed(HttpServerResponse.empty({ status: 403 })) + const withAllowedOrigin = ( + handler: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect + ) => + (request: HttpServerRequest.HttpServerRequest) => + isAllowedMcpOrigin(request, options.allowedOrigins) ? handler(request) : forbidden + const methodNotAllowedResponse = Effect.succeed(HttpServerResponse.empty({ status: 405, headers: { allow: allowSessionTermination ? "POST, DELETE" : "POST" } - }) - const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => - Effect.succeed( - isAllowedMcpOrigin(request, options.allowedOrigins) - ? methodNotAllowedResponse - : HttpServerResponse.empty({ status: 403 }) - ) + })) + const methodNotAllowed = withAllowedOrigin(() => methodNotAllowedResponse) for (const method of ["GET", "PUT", "PATCH", "OPTIONS"] as const) { yield* router.add(method, options.path, methodNotAllowed) } @@ -1634,10 +1634,7 @@ const layerMcpProtocolHttp = (options: { "DELETE", options.path, allowSessionTermination - ? (request) => - isAllowedMcpOrigin(request, options.allowedOrigins) - ? runtime.terminateHttpSession(request.headers) - : Effect.succeed(HttpServerResponse.empty({ status: 403 })) + ? withAllowedOrigin((request) => runtime.terminateHttpSession(request.headers)) : methodNotAllowed ) yield* router.add("POST", options.path, (request) => { diff --git a/packages/effect/test/ai/McpServer/McpServer.test.ts b/packages/effect/test/ai/McpServer/McpServer.test.ts index 6950292f36f..a85313a40d1 100644 --- a/packages/effect/test/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/ai/McpServer/McpServer.test.ts @@ -2332,21 +2332,7 @@ describe("McpServer", () => { allowSessionTermination: true }) ))) - const initialized = yield* harness.post({ - jsonrpc: "2.0", - id: "initialize", - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: { elicitation: { url: {} } }, - clientInfo: { name: "authorization-client", version: "1.0.0" } - } - }) - yield* readMcpHttpResponse(initialized) - const sessionId = initialized.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - const headers = { "Mcp-Session-Id": sessionId, "Mcp-Protocol-Version": "2025-11-25" } - yield* harness.post({ jsonrpc: "2.0", method: "notifications/initialized" }, headers) + const headers = yield* initializeHttpSession(harness, McpProtocol.v2025_11_25, { elicitation: { url: {} } }) const response = yield* harness.post({ jsonrpc: "2.0", id: "authorize-tool", diff --git a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts index 97d31699efc..2493776177f 100644 --- a/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts +++ b/packages/effect/test/ai/McpServer/TestUtils/McpHttpHarness.ts @@ -67,7 +67,8 @@ export const makeHttpHarness = Effect.fnUntraced(function*( export const initializeHttpSession = Effect.fnUntraced(function*( harness: Effect.Success>, - selectedProtocol: McpProtocol.ProtocolAdapter + selectedProtocol: McpProtocol.ProtocolAdapter, + capabilities: Record = {} ) { const response = yield* harness.post({ jsonrpc: "2.0", @@ -75,7 +76,7 @@ export const initializeHttpSession = Effect.fnUntraced(function*( method: "initialize", params: { protocolVersion: selectedProtocol.protocolVersion, - capabilities: {}, + capabilities, clientInfo: { name: "test", version: "1.0.0" } } })