From 3550cee2fcf3ea614c5f29eac264bbf53fad2b94 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Fri, 14 Aug 2026 11:26:04 +0200 Subject: [PATCH 1/9] refactor: introduce MCP lifecycle runtimes and request context --- .changeset/neat-eyes-behave.md | 5 + .../effect/src/unstable/ai/McpProtocol.ts | 72 +- packages/effect/src/unstable/ai/McpSchema.ts | 37 +- packages/effect/src/unstable/ai/McpServer.ts | 945 +++++++----------- .../src/unstable/ai/internal/mcpCore.ts | 29 +- .../src/unstable/ai/internal/mcpProtocol.ts | 67 +- .../ai/internal/mcpProtocol/v2024_11_05.ts | 9 +- .../ai/internal/mcpProtocol/v2025_03_26.ts | 9 +- .../ai/internal/mcpProtocol/v2025_06_18.ts | 9 +- .../ai/internal/mcpProtocol/v2025_11_25.ts | 9 +- .../ai/internal/mcpProtocolRegistry.ts | 6 +- .../src/unstable/ai/internal/mcpRuntime.ts | 444 ++++++++ .../ai/internal/mcpStatefulRuntime.ts | 185 ++++ .../unstable/ai/McpServer/McpProtocol.test.ts | 9 +- .../unstable/ai/McpServer/McpServer.test.ts | 54 +- .../ai/McpServer/ProtocolAdapters.test.ts | 4 +- 16 files changed, 1273 insertions(+), 620 deletions(-) create mode 100644 .changeset/neat-eyes-behave.md create mode 100644 packages/effect/src/unstable/ai/internal/mcpRuntime.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpStatefulRuntime.ts diff --git a/.changeset/neat-eyes-behave.md b/.changeset/neat-eyes-behave.md new file mode 100644 index 00000000000..249cc3727fb --- /dev/null +++ b/.changeset/neat-eyes-behave.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +MCP protocol adapters now declare stateful or stateless lifecycle policy while `McpServer` owns the corresponding runtime state. `McpRequestContext` exposes request facts to handlers without requiring an initialized session. Common handlers and `clientCapabilities` now use this request context, while legacy reverse operations continue to use `McpServerClient`. `EnabledWhen` receives the selected protocol and capabilities with optional client information. diff --git a/packages/effect/src/unstable/ai/McpProtocol.ts b/packages/effect/src/unstable/ai/McpProtocol.ts index 88f370c1e54..ceef8f7a6c4 100644 --- a/packages/effect/src/unstable/ai/McpProtocol.ts +++ b/packages/effect/src/unstable/ai/McpProtocol.ts @@ -65,18 +65,80 @@ export interface ErasedClientRpcGroup extends ErasedRpcGroup { readonly prefix: (prefix: string) => RpcGroup.RpcGroup } +/** + * Transport behavior declared by an MCP runtime descriptor. + * + * @category models + * @since 4.0.0 + */ +export interface TransportPolicy { + readonly jsonRpc: { + readonly acceptsBatches: boolean + } + readonly http: { + readonly requiresVersionHeader?: boolean | undefined + } +} + +/** + * Request information decoded by a stateless MCP runtime. + * + * @category models + * @since 4.0.0 + */ +export interface StatelessRuntimeProfile { + readonly protocolVersion: string + readonly clientCapabilities: Schema.JsonObject + readonly clientInfo?: McpSchema.Implementation | undefined + readonly requestMetadata: Schema.JsonObject +} + +/** + * Stateful lifecycle runtime declaration carried by a protocol adapter. + * + * @category models + * @since 4.0.0 + */ +export interface StatefulRuntimeDescriptor { + readonly _tag: "Stateful" + readonly transport: TransportPolicy +} + +/** + * Stateless lifecycle runtime declaration carried by a protocol adapter. + * + * @category models + * @since 4.0.0 + */ +export interface StatelessRuntimeDescriptor { + readonly _tag: "Stateless" + readonly transport: TransportPolicy + readonly profileFromRequestMetadata: ( + metadata: unknown + ) => Effect.Effect +} + +/** + * Lifecycle runtime declaration carried by a protocol adapter. + * + * @category models + * @since 4.0.0 + */ +export type RuntimeDescriptor = StatefulRuntimeDescriptor | StatelessRuntimeDescriptor + /** * The operational shape shared by protocol adapters. * * @category models * @since 4.0.0 */ -export interface AnyProtocolAdapter { +export interface AnyProtocolAdapter< + out Version extends string = string, + HandlerRequirements = unknown, + out Runtime extends RuntimeDescriptor = RuntimeDescriptor +> { readonly protocolVersion: Version - readonly transport: { - readonly acceptsJsonRpcBatches: boolean - readonly requiresVersionHeader: boolean - } + readonly runtime: Runtime readonly clientRpcs: ErasedClientRpcGroup readonly clientNotificationRpcs: ErasedRpcGroup readonly serverRequestRpcs: RpcGroup.Any diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index 462433ed350..fbbf7b2dbf2 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -2647,6 +2647,28 @@ export interface McpReverseClient { ) => Effect.Effect } +/** + * Protocol-neutral context available while handling an MCP request. + * + * **Details** + * + * Unlike `McpServerClient`, this service does not imply an initialized session + * or support for server-initiated requests. + * + * @category services + * @since 4.0.0 + */ +export class McpRequestContext extends Context.Service + | Schema.JsonObject + | undefined +}>()("effect/ai/McpSchema/McpRequestContext") {} + /** * Service available while handling an MCP client request. * @@ -3021,8 +3043,13 @@ export function param( * @category services * @since 4.0.0 */ -export class EnabledWhen - extends Context.Service>()( - "effect/unstable/ai/McpSchema/EnabledWhen" - ) -{} +export class EnabledWhen extends Context.Service< + EnabledWhen, + Predicate.Predicate<{ + readonly protocolVersion: string + readonly capabilities: ClientCapabilities + readonly clientInfo?: Implementation | undefined + }> +>()( + "effect/unstable/ai/McpSchema/EnabledWhen" +) {} diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index bacea160097..a68b1d328b2 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -2,8 +2,8 @@ * Builds Model Context Protocol (MCP) servers with Effect. * * The `McpServer` service stores the tools, resources, resource templates, - * prompts, completions, initialized clients, and outgoing notifications exposed - * by a server. This module also includes the server runner, custom protocol, + * prompts, completions, and outgoing notifications exposed by a server. This + * module also includes the server runner, custom protocol, * stdio, and HTTP layers, registration helpers, and APIs that let handlers ask * the connected client for structured input or read its advertised * capabilities. @@ -18,7 +18,6 @@ import * as Effect from "../../Effect.ts" import * as Exit from "../../Exit.ts" import * as Fiber from "../../Fiber.ts" import * as Layer from "../../Layer.ts" -import * as LogLevel from "../../LogLevel.ts" import * as Option from "../../Option.ts" import * as Predicate from "../../Predicate.ts" import * as Queue from "../../Queue.ts" @@ -38,14 +37,14 @@ import * as HttpServerRequest from "../http/HttpServerRequest.ts" import * as HttpServerResponse from "../http/HttpServerResponse.ts" import * as Rpc from "../rpc/Rpc.ts" import * as RpcClient from "../rpc/RpcClient.ts" -import * as RpcGroup from "../rpc/RpcGroup.ts" +import type * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcMessage from "../rpc/RpcMessage.ts" import * as RpcSerialization from "../rpc/RpcSerialization.ts" import * as RpcServer from "../rpc/RpcServer.ts" import * as AiError from "./AiError.ts" import * as McpCore from "./internal/mcpCore.ts" import * as McpProtocolInternal from "./internal/mcpProtocol.ts" -import * as McpProtocolRegistry from "./internal/mcpProtocolRegistry.ts" +import * as McpRuntime from "./internal/mcpRuntime.ts" import type * as McpProtocol from "./McpProtocol.ts" import * as McpSchema from "./McpSchema.ts" import { @@ -53,15 +52,14 @@ import { ElicitationDeclined, EnabledWhen, GetPromptResult, - Initialize, InternalError, InvalidParams, InvalidRequest, isParam, + McpRequestContext, McpServerClient, McpServerClientMiddleware, MethodNotFound, - Ping, Prompt, Resource, ResourceTemplate, @@ -158,6 +156,20 @@ const toInternalServerNotification = ( } } +const provideInvocationContext = ( + effect: Effect.Effect, + invocation: McpCore.McpInvocation +): Effect.Effect> => { + const provided = Effect.provideService(effect, McpRequestContext, invocation.requestContext) + return (invocation.serverClient === undefined + ? provided + : Effect.provideService(provided, McpServerClient, invocation.serverClient)) as Effect.Effect< + A, + E, + Exclude + > +} + /** * Service that stores and serves an MCP server's registered tools, resources, * prompts, completions, and outgoing notifications. @@ -176,7 +188,6 @@ export class McpServer extends Context.Service Effect.Effect - readonly initializedClients: Set readonly tools: ReadonlyArray<{ readonly tool: McpTool readonly annotations: Context.Context @@ -184,7 +195,9 @@ export class McpServer extends Context.Service - readonly handle: (payload: any) => Effect.Effect + readonly handle: ( + payload: any + ) => Effect.Effect }) => Effect.Effect readonly callTool: ( requests: typeof CallTool.payloadSchema.Type @@ -197,7 +210,11 @@ export class McpServer extends Context.Service - readonly handle: Effect.Effect + readonly handle: Effect.Effect< + typeof ReadResourceResult.Type, + InternalError, + McpRequestContext | McpServerClient + > }) => Effect.Effect readonly resourceTemplates: ReadonlyArray<{ @@ -222,7 +239,7 @@ export class McpServer extends Context.Service Effect.Effect< typeof ReadResourceResult.Type, InvalidParams | InternalError, - McpServerClient + McpRequestContext | McpServerClient > } ) => Effect.Effect @@ -243,11 +260,11 @@ export class McpServer extends Context.Service Effect.Effect + ) => Effect.Effect > readonly handle: ( params: Record - ) => Effect.Effect + ) => Effect.Effect }) => Effect.Effect readonly getPromptResult: ( request: typeof GetPrompt.payloadSchema.Type @@ -323,7 +340,6 @@ export class McpServer extends Context.Service - options.handle(call.arguments).pipe( - Effect.provideService( - McpServerClient, - invocation.requestContext - ), + provideInvocationContext(options.handle(call.arguments), invocation).pipe( Effect.catchTags({ InternalError: (error) => Effect.fail( @@ -385,15 +397,10 @@ export class McpServer extends Context.Service Effect.gen(function*() { const client = yield* McpServerClient - const result = yield* internalCore.tools.call(request, { - clientId: client.clientId, - protocol: { - protocolVersion: client.protocolVersion, - clientCapabilities: client.initializePayload.capabilities, - clientInfo: client.initializePayload.clientInfo - }, - requestContext: client - }).pipe( + const result = yield* internalCore.tools.call( + request, + McpProtocolInternal.invocationFromClient(client) + ).pipe( Effect.mapError((error) => new InvalidParams({ message: error._tag === "ToolNotFound" @@ -428,10 +435,7 @@ export class McpServer extends Context.Service - options.handle.pipe( - Effect.provideService(McpServerClient, invocation.requestContext) - ) + read: (invocation) => provideInvocationContext(options.handle, invocation) }) yield* notifications.client["notifications/resources/list_changed"]({}) }), @@ -468,10 +472,7 @@ export class McpServer extends Context.Service - handle(uri, Array.from(params)).pipe( - Effect.provideService(McpServerClient, invocation.requestContext) - ) + read: (uri, params, invocation) => provideInvocationContext(handle(uri, Array.from(params)), invocation) }) for (const [param, handle] of Object.entries(completions)) { yield* internalCore.completions.register( @@ -492,16 +493,10 @@ export class McpServer extends Context.Service Effect.gen(function*() { const client = yield* McpServerClient - return yield* internalCore.resources.read(uri, { - clientId: client.clientId, - protocol: { - protocolVersion: client.protocolVersion, - clientCapabilities: client.clientCapabilities, - clientInfo: client.clientInfo, - requestMetadata: client.initializePayload._meta - }, - requestContext: client - }).pipe( + return yield* internalCore.resources.read( + uri, + McpProtocolInternal.invocationFromClient(client) + ).pipe( Effect.catchTag("ResourceNotFound", (error) => Effect.fail(new InvalidParams({ message: `Resource '${error.uri}' not found` }))) ) @@ -527,20 +522,13 @@ export class McpServer extends Context.Service - options.handle(params).pipe( - Effect.provideService(McpServerClient, invocation.requestContext) - ) + get: (params, invocation) => provideInvocationContext(options.handle(params), invocation) }) for (const [param, handle] of Object.entries(options.completions)) { yield* internalCore.completions.register( `prompt/${options.prompt.name}/${param}`, (request, invocation) => - handle(request.argument.value, request.context).pipe( - Effect.provideService( - McpServerClient, - invocation.requestContext - ), + provideInvocationContext(handle(request.argument.value, request.context), invocation).pipe( Effect.map((result) => ({ values: result.completion.values, total: result.completion.total, @@ -599,61 +587,24 @@ const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" const MCP_INVALID_BATCH_METHOD = "invalid/json-rpc-batch" const requestKey = (requestId: string | number): string => `${typeof requestId}:${requestId}` -type SessionLogLevel = - | { readonly _tag: "Effect"; readonly level: LogLevel.LogLevel } - | { readonly _tag: "Mcp"; readonly level: McpSchema.LoggingLevel } - -interface Session { - readonly initializePayload: typeof Initialize.payloadSchema.Type - readonly negotiatedProfile: McpCore.NegotiatedProtocolProfile - readonly protocol: McpProtocol.ProtocolAdapter - readonly resourceSubscriptions: Set | undefined - logLevel: SessionLogLevel -} - -interface Sessions { - readonly bySessionId: Map - readonly byClientId: Map +interface ActiveRequest { + readonly prepared: McpRuntime.PreparedRequest + readonly cancelled: boolean } class McpClientKey extends Data.Class<{ readonly clientId: number - readonly profile: McpCore.NegotiatedProtocolProfile + readonly profile: McpCore.NegotiatedProtocolProfile }> {} -class McpProtocolState extends Context.Service -}>()("effect/ai/McpServer/McpProtocolState") {} - -const makeMcpProtocolState = Effect.fnUntraced(function*( - protocols: Arr.NonEmptyReadonlyArray -) { - // TODO: Replace the shared session map with an adapter-owned lifecycle strategy - // before v2026-07-28. The strategy must let sessionful revisions pin a profile - // after initialize while stateless revisions select and derive it per request. - return McpProtocolState.of({ - sessions: { - bySessionId: new Map(), - byClientId: new Map() - }, - protocolRegistry: yield* McpProtocolRegistry.make(protocols) - }) -}) - -const layerMcpProtocolState = ( - protocols: Arr.NonEmptyReadonlyArray -): Layer.Layer => - Layer.effect(McpProtocolState)(makeMcpProtocolState(protocols)) - /** * Runs an MCP server over the current `RpcServer.Protocol`. * * **Details** * - * The server performs initialization and session handling, serves registered - * tools, resources, and prompts, and forwards queued server notifications to - * initialized clients. + * The server serves registered tools, resources, and prompts. The selected MCP + * runtime handles protocol lifecycle state and determines which clients receive + * queued server notifications. * * @category running * @since 4.0.0 @@ -679,39 +630,38 @@ export const run: (options: { readonly protocols: Arr.NonEmptyReadonlyArray readonly extensions?: ServerExtensions | undefined }) { - const protocolStateOption = yield* Effect.serviceOption(McpProtocolState) - const protocolState = Option.isSome(protocolStateOption) - ? protocolStateOption.value - : yield* makeMcpProtocolState(options.protocols) - return yield* runWithProtocolState(options, protocolState) + const runtimeOption = yield* Effect.serviceOption(McpRuntime.ServerRuntime) + const runtime = Option.isSome(runtimeOption) + ? runtimeOption.value + : yield* McpRuntime.make(options.protocols) + return yield* runWithRuntime(options, runtime) }) -const runWithProtocolState = Effect.fnUntraced(function*(options: { +const runWithRuntime = Effect.fnUntraced(function*(options: { readonly name: string readonly version: string readonly description?: string | undefined readonly websiteUrl?: string | undefined readonly icons?: ReadonlyArray | undefined readonly extensions?: ServerExtensions | undefined -}, protocolState: McpProtocolState["Service"]) { - const protocolRegistry = protocolState.protocolRegistry +}, runtime: McpRuntime.ServerRuntimeShape) { const serverScope = yield* Effect.scope const protocol = yield* RpcServer.Protocol const server = yield* McpServer const defaultLogLevel = yield* CurrentLogLevel const isHttp = Option.isSome(yield* Effect.serviceOption(HttpRouter.HttpRouter)) - const sessions = protocolState.sessions - const clientProtocols = new Map() - const activeRequests = new Map>() - const clientProfiles = new Map() - const handlers = yield* Layer.build(layerHandlers(options, { - sessions, - protocolRegistry - })) + const clientProtocols = new Map() + const activeRequests = new Map>() + const clientProfiles = new Map>() + const handlers = yield* runtime.installHandlers({ + core: internalState.get(server)!.core, + defaultLogLevel, + serverInfo: options + }) const clients = yield* RcMap.make({ lookup: Effect.fnUntraced(function*(key: McpClientKey) { - const selectedProtocol = protocolRegistry.select(key.profile.protocolVersion) + const selectedProtocol = runtime.selectProtocol(key.profile.protocolVersion) let write!: (message: RpcMessage.FromServerEncoded) => Effect.Effect const reverseProtocol = yield* RpcClient.Protocol.make(Effect.fnUntraced(function*(writeResponse) { let cid = 0 @@ -742,7 +692,7 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { }) const clientMiddleware = McpServerClientMiddleware.of((effect, { client, headers, payload, rpc }) => { - const session = getClientSession(sessions, client.id, headers) + const session = runtime.resolveRequest(client.id, headers) const isInitialize = rpc._tag.endsWith("/initialize") if (!isInitialize && !session) { const fiber = Fiber.getCurrent()! @@ -760,40 +710,56 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { } return Effect.die(new Error(`Mcp-Session-Id does not exist`)) } - const selectedProtocol = session?.protocol ?? protocolForInternalTag(protocolRegistry, rpc._tag) - // NOTE: RPC middleware erases the correlation between the initialize tag + // RPC middleware erases the correlation between the initialize tag // and its decoded payload. Restore it once after non-initialize requests // without a session have been rejected above. - const initializePayload = session?.initializePayload ?? payload as typeof Initialize.payloadSchema.Type - const profile = session?.negotiatedProfile ?? { + const initializePayload = session?.initializePayload ?? payload as typeof McpSchema.Initialize.payloadSchema.Type + const selectedProtocol = session?.protocol ?? + clientProtocols.get(client.id) ?? + runtime.protocolForInternalTag(rpc._tag) + if (!isProtocolVersion(selectedProtocol.protocolVersion)) { + return Effect.die(`Unsupported selected MCP protocol version: ${selectedProtocol.protocolVersion}`) + } + const profile: McpCore.NegotiatedProtocolProfile = session?.negotiatedProfile ?? { protocolVersion: selectedProtocol.protocolVersion, clientCapabilities: initializePayload.capabilities, clientInfo: initializePayload.clientInfo } clientProfiles.set(client.id, profile) + const requestContext = McpRequestContext.of({ + clientId: client.id, + protocolVersion: profile.protocolVersion, + clientCapabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + requestMetadata: initializePayload._meta + }) return Effect.provideService( Effect.provideService( - effect, - McpServerClient, - McpServerClient.of({ - clientId: client.id, - protocolVersion: session?.negotiatedProfile.protocolVersion ?? selectedProtocol.protocolVersion, - clientCapabilities: profile.clientCapabilities, - clientInfo: profile.clientInfo, - initializePayload, - getClient: RcMap.get( - clients, - new McpClientKey({ - clientId: client.id, - profile - }) - ).pipe( - Effect.map(({ client }) => client) - ) - }) + Effect.provideService( + effect, + McpServerClient, + McpServerClient.of({ + clientId: client.id, + protocolVersion: profile.protocolVersion, + clientCapabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + initializePayload, + getClient: RcMap.get( + clients, + new McpClientKey({ + clientId: client.id, + profile + }) + ).pipe( + Effect.map(({ client }) => client) + ) + }) + ), + McpRequestContext, + requestContext ), CurrentLogLevel, - effectLogLevel(session?.logLevel, defaultLogLevel) + runtime.effectLogLevel(client.id, headers, defaultLogLevel) ) }) @@ -803,7 +769,7 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (response._tag === "Exit") { const requests = activeRequests.get(clientId) const key = requestKey(response.requestId) - const cancelled = requests?.get(key) + const cancelled = requests?.get(key)?.cancelled if (requests !== undefined && requests.delete(key) && requests.size === 0) { activeRequests.delete(clientId) } @@ -859,147 +825,188 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { HttpServerRequest.HttpServerRequest ).headers : Headers.fromInput(request.headers) - const session = getClientSession(sessions, clientId, headers) - const selectedProtocol = session?.protocol ?? - (request.tag === "initialize" - ? protocolRegistry.select(getOfferedProtocolVersion(request.payload)) - : protocolRegistry.protocols[0]) - // Selection happens before dated payload decoding. Once a - // session exists, all later messages reuse its pinned adapter. - clientProtocols.set(clientId, selectedProtocol) - if (request.tag === MCP_INVALID_BATCH_METHOD) { - return protocol.send(clientId, { - _tag: "Exit", - requestId: request.id, - exit: { - _tag: "Failure", - cause: [{ - _tag: "Fail", - error: new InvalidRequest({ message: "JSON-RPC batches are not supported" }) - }] + const cancellationRequest = request.tag === "notifications/cancelled" && + typeof request.payload === "object" && request.payload !== null && "requestId" in request.payload && + (typeof request.payload.requestId === "string" || typeof request.payload.requestId === "number") + ? activeRequests.get(clientId)?.get(requestKey(request.payload.requestId)) + : undefined + const prepare = cancellationRequest === undefined + ? runtime.prepareRequest(clientId, headers, request) + : Effect.succeed(cancellationRequest.prepared) + return prepare.pipe( + Effect.flatMap((prepared) => { + const session = prepared.binding + const selectedProtocol = prepared.protocol + // Selection happens before dated payload decoding. Once a + // session exists, all later messages reuse its pinned adapter. + clientProtocols.set(clientId, selectedProtocol) + if (prepared.profile !== undefined) { + clientProfiles.set(clientId, prepared.profile) } - }) - } - if (isHttp) { - const fiber = Fiber.getCurrent()! - const httpRequest = Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest) - if (session) { - appendPreResponseHandlerUnsafe(httpRequest, (_, res) => - Effect.succeed( - HttpServerResponse.setHeader( - res, - MCP_PROTOCOL_VERSION_HEADER, - session.protocol.protocolVersion - ) - )) - } - } - const routedRequest = protocolRegistry.routeClientRequest(selectedProtocol, request) - const rpc = protocolRegistry.clientRpcs.requests.get(routedRequest.tag) - if ( - rpc && - selectedProtocol.clientNotificationRpcs.requests.has(request.tag) - ) { - if (!session) { - if (httpRequest) { - appendPreResponseHandlerUnsafe( - httpRequest, - () => + if (request.tag === MCP_INVALID_BATCH_METHOD) { + return protocol.send(clientId, { + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: new InvalidRequest({ message: "JSON-RPC batches are not supported" }) + }] + } + }) + } + if (isHttp) { + const fiber = Fiber.getCurrent()! + const httpRequest = Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest) + if (session) { + appendPreResponseHandlerUnsafe(httpRequest, (_, res) => Effect.succeed( - HttpServerResponse.empty({ - status: headers[MCP_SESSION_ID_HEADER] === undefined ? 400 : 404 - }) + HttpServerResponse.setHeader( + res, + MCP_PROTOCOL_VERSION_HEADER, + session.protocol.protocolVersion + ) + )) + } + } + const routedRequest = runtime.routeClientRequest(selectedProtocol, request) + const rpc = runtime.clientRpcs.requests.get(routedRequest.tag) + if ( + rpc && + selectedProtocol.clientNotificationRpcs.requests.has(request.tag) + ) { + if (!session && selectedProtocol.runtime._tag === "Stateful") { + if (httpRequest) { + appendPreResponseHandlerUnsafe( + httpRequest, + () => + Effect.succeed( + HttpServerResponse.empty({ + status: headers[MCP_SESSION_ID_HEADER] === undefined ? 400 : 404 + }) + ) ) + } + return Effect.void + } + const decode = selectedProtocol.payloadCodecs(rpc).decode(request.payload) + return decode.pipe( + Effect.flatMap((payload) => { + if ( + request.tag === "notifications/roots/list_changed" && + session?.initializePayload.capabilities.roots?.listChanged === true && + httpRequest === undefined + ) { + return RcMap.get( + clients, + new McpClientKey({ + clientId, + profile: session.negotiatedProfile + }) + ).pipe( + Effect.flatMap(({ client }) => client.listRoots()), + Effect.scoped, + Effect.ignoreCause, + Effect.forkIn(serverScope), + Effect.asVoid + ) + } + if (request.tag === "notifications/cancelled") { + return selectedProtocol.normalizeCancellation(payload).pipe( + Effect.flatMap((cancellation) => { + const key = requestKey(cancellation.requestId) + const requests = activeRequests.get(clientId) + if (requests?.has(key) !== true) { + return Effect.void + } + requests.set(key, { ...requests.get(key)!, cancelled: true }) + return f(clientId, { + _tag: "Interrupt", + requestId: cancellation.requestId + }) + }) + ) + } + const handler = handlers.mapUnsafe.get(rpc.key) as Rpc.Handler | undefined + const handled = handler + ? handler.handler(payload, { + rpc, + requestId: RpcMessage.RequestId(request.id), + client: new Rpc.ServerClient(clientId), + headers + }) as any as Effect.Effect + : Effect.void + return prepared.requestContext === undefined + ? handled + : Effect.provideService(handled, McpRequestContext, prepared.requestContext) + }), + Effect.catchCause(() => Effect.void) ) } - return Effect.void - } - const decode = selectedProtocol.payloadCodecs(rpc).decode(request.payload) - return decode.pipe( - Effect.flatMap((payload) => { - if ( - request.tag === "notifications/roots/list_changed" && - session.initializePayload.capabilities.roots?.listChanged === true && - httpRequest === undefined - ) { - return RcMap.get( - clients, - new McpClientKey({ - clientId, - profile: session.negotiatedProfile - }) - ).pipe( - Effect.flatMap(({ client }) => client.listRoots()), - Effect.scoped, - Effect.ignoreCause, - Effect.forkIn(serverScope), - Effect.asVoid - ) + if (!rpc) { + if (request.isNotification) { + return Effect.void } - if (request.tag === "notifications/cancelled") { - return selectedProtocol.normalizeCancellation(payload).pipe( - Effect.flatMap((cancellation) => { - const key = requestKey(cancellation.requestId) - const requests = activeRequests.get(clientId) - if (requests?.has(key) !== true) { - return Effect.void - } - requests.set(key, true) - return f(clientId, { - _tag: "Interrupt", - requestId: String(cancellation.requestId) - }) - }) - ) - } - const handler = handlers.mapUnsafe.get(rpc.key) as Rpc.Handler | undefined - return handler - ? handler.handler(payload, { - rpc, - requestId: RpcMessage.RequestId(request.id), - client: new Rpc.ServerClient(clientId), - headers - }) as any as Effect.Effect - : Effect.void - }), - Effect.ignoreCause - ) - } - if (!rpc) { - if (request.isNotification) { - return Effect.void - } - return protocol.send(clientId, { - _tag: "Exit", - requestId: request.id, - exit: { - _tag: "Failure", - cause: [{ _tag: "Fail", error: new MethodNotFound({ message: `Method not found: ${request.tag}` }) }] + return protocol.send(clientId, { + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: new MethodNotFound({ message: `Method not found: ${request.tag}` }) + }] + } + }) } - }) - } - return selectedProtocol.payloadCodecs(rpc).decode(request.payload).pipe( - Effect.matchEffect({ - onSuccess: () => { - if (request.isNotification !== true) { - const requests = activeRequests.get(clientId) ?? new Map() - requests.set(requestKey(request.id), false) - activeRequests.set(clientId, requests) - } - return f(clientId, routedRequest) - }, - onFailure: () => - request.isNotification - ? Effect.void - : protocol.send(clientId, { - _tag: "Exit", - requestId: request.id, - exit: { - _tag: "Failure", - cause: [{ _tag: "Fail", error: new InvalidParams({ message: "Invalid method parameters" }) }] + return selectedProtocol.payloadCodecs(rpc).decode(request.payload).pipe( + Effect.matchEffect({ + onSuccess: () => { + if (request.isNotification !== true) { + const requests = activeRequests.get(clientId) ?? new Map() + requests.set(requestKey(request.id), { prepared, cancelled: false }) + activeRequests.set(clientId, requests) } - }) - }) + const handled = f(clientId, routedRequest) + return prepared.requestContext === undefined + ? handled + : Effect.provideService(handled, McpRequestContext, prepared.requestContext) + }, + onFailure: () => + request.isNotification + ? Effect.void + : protocol.send(clientId, { + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: new InvalidParams({ message: "Invalid method parameters" }) + }] + } + }) + }) + ) + }), + Effect.catch((error) => + request.isNotification + ? Effect.void + : protocol.send(clientId, { + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: error instanceof McpProtocolInternal.ProtocolError + ? error + : new InvalidParams({ message: "Invalid request metadata" }) + }] + } + }) + ) ) } case "Ping": @@ -1010,16 +1017,14 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { activeRequests.delete(clientId) clientProtocols.delete(clientId) clientProfiles.delete(clientId) - if (!isHttp) { - sessions.byClientId.delete(clientId) - } + runtime.disconnect(clientId) return f(clientId, request) case "Pong": case "Exit": case "Chunk": case "ClientProtocolError": case "Defect": { - const selectedProtocol = getProtocolForClient(clientProtocols, clientId, protocolRegistry) + const selectedProtocol = getProtocolForClient(clientProtocols, clientId, runtime.protocols[0]) const profile = clientProfiles.get(clientId) ?? { protocolVersion: selectedProtocol.protocolVersion, clientCapabilities: {}, @@ -1047,18 +1052,16 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (!clientIds.has(clientId)) { clientProtocols.delete(clientId) clientProfiles.delete(clientId) - // HTTP client IDs are request-scoped; their UUID sessions outlive them. - if (!isHttp) { - sessions.byClientId.delete(clientId) - } + // HTTP UUID sessions are stored separately and outlive request-scoped client IDs. + runtime.disconnect(clientId) } } - for (const clientId of server.initializedClients.keys()) { + for (const clientId of runtime.deliveryClientIds()) { if (targetClientId !== undefined && clientId !== targetClientId) { continue } if (!clientIds.has(clientId)) { - server.initializedClients.delete(clientId) + runtime.disconnect(clientId) continue } const selectedProtocol = clientProtocols.get(clientId) @@ -1070,17 +1073,7 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (projected === undefined) { return } - const session = sessions.byClientId.get(clientId) - if ( - notification._tag === "LoggingMessage" && - !isMcpLogLevelEnabled(notification.level, session?.logLevel, defaultLogLevel) - ) { - return - } - if ( - notification._tag === "ResourceUpdated" && - session?.resourceSubscriptions?.has(notification.uri) !== true - ) { + if (!runtime.canDeliver(clientId, Headers.empty, notification, defaultLogLevel)) { return } const rpc = selectedProtocol.serverNotificationRpcs.requests.get(projected.tag) @@ -1104,7 +1097,7 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { Effect.forkScoped ) - return yield* RpcServer.make(protocolRegistry.clientRpcs, { + return yield* RpcServer.make(runtime.clientRpcs, { spanPrefix: "McpServer", disableFatalDefects: true }).pipe( @@ -1116,8 +1109,9 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { /** * Creates a layer that starts an MCP server over an existing - * `RpcServer.Protocol` and provides the `McpServer` and `McpServerClient` - * services. + * `RpcServer.Protocol` and provides `McpServer`. Request handlers receive + * `McpRequestContext`; initialized legacy requests additionally receive + * `McpServerClient`. * * **When to use** * @@ -1152,22 +1146,22 @@ export const layer = (options: { readonly protocols: Arr.NonEmptyReadonlyArray readonly extensions?: ServerExtensions | undefined }): Layer.Layer => - layerWithProtocolState(options).pipe( - Layer.provide(layerMcpProtocolState(options.protocols)) + layerWithRuntime(options).pipe( + Layer.provide(McpRuntime.layer(options.protocols)) ) -const layerWithProtocolState = (options: { +const layerWithRuntime = (options: { readonly name: string readonly version: string readonly description?: string | undefined readonly websiteUrl?: string | undefined readonly icons?: ReadonlyArray | undefined readonly extensions?: ServerExtensions | undefined -}): Layer.Layer => +}): Layer.Layer => Layer.effectDiscard( Effect.gen(function*() { - const protocolState = yield* McpProtocolState - yield* Effect.forkScoped(runWithProtocolState(options, protocolState)) + const runtime = yield* McpRuntime.ServerRuntime + yield* Effect.forkScoped(runWithRuntime(options, runtime)) }) ).pipe( Layer.provideMerge(McpServer.layer) @@ -1184,7 +1178,8 @@ const layerWithProtocolState = (options: { * **Details** * * The selected protocol adapter controls the dated RPC schemas and JSON-RPC - * batch policy. The layer provides `McpServer` and `McpServerClient` and + * batch policy. The layer provides `McpServer`, supplies request-scoped + * `McpRequestContext` and legacy `McpServerClient` services to handlers, and * requires `Stdio`. * * @see {@link layer} for running over an existing `RpcServer.Protocol` @@ -1228,7 +1223,7 @@ const mcpStdioSerialization = ( const decoded: Array = [] for (const frame of frames.decode(data)) { if (Array.isArray(frame)) { - const acceptsBatch = selectedProtocol?.transport.acceptsJsonRpcBatches === true + const acceptsBatch = selectedProtocol?.runtime.transport.jsonRpc.acceptsBatches === true if ( !acceptsBatch || frame.length === 0 || @@ -1284,7 +1279,9 @@ const mcpStdioSerialization = ( * **Details** * * POST serves JSON-RPC and accepted notification-only requests return `202`. - * Unsupported protocol versions return `400`; methods without MCP handlers + * Modern routing-header mismatches and unsupported protocol versions return + * JSON-RPC errors with status `400`; unknown modern RPC methods return a + * JSON-RPC method-not-found error with status `404`. Unsupported HTTP methods * return `405`. Requests carrying an `Origin` header are rejected unless the * exact origin appears in `allowedOrigins`; Origin-less non-browser clients * remain valid. The surrounding HTTP server remains responsible for binding @@ -1313,7 +1310,7 @@ export const layerHttp = (options: { readonly extensions?: ServerExtensions | undefined readonly allowedOrigins?: ReadonlyArray | undefined }): Layer.Layer => { - const protocolState = layerMcpProtocolState(options.protocols) + const runtime = McpRuntime.layer(options.protocols) const methodNotAllowedResponse = HttpServerResponse.empty({ status: 405, headers: { allow: "POST" } @@ -1329,9 +1326,9 @@ export const layerHttp = (options: { HttpRouter.add("DELETE", options.path, methodNotAllowed), HttpRouter.add("OPTIONS", options.path, methodNotAllowed) ) - return Layer.merge(layerWithProtocolState(options), routes).pipe( + return Layer.merge(layerWithRuntime(options), routes).pipe( Layer.provide(layerMcpProtocolHttp(options)), - Layer.provide(protocolState), + Layer.provide(runtime), Layer.provide(RpcSerialization.layerJsonRpc()) ) } @@ -1342,10 +1339,10 @@ const layerMcpProtocolHttp = (options: { }): Layer.Layer< RpcServer.Protocol, never, - McpProtocolState | RpcSerialization.RpcSerialization | HttpRouter.HttpRouter + McpRuntime.ServerRuntime | RpcSerialization.RpcSerialization | HttpRouter.HttpRouter > => Layer.effect(RpcServer.Protocol)(Effect.gen(function*() { - const state = yield* McpProtocolState + const runtime = yield* McpRuntime.ServerRuntime const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, (request) => { @@ -1359,26 +1356,7 @@ const layerMcpProtocolHttp = (options: { if (!accepted.includes("application/json") || !accepted.includes("text/event-stream")) { return Effect.succeed(HttpServerResponse.empty({ status: 406 })) } - const protocolVersion = request.headers[MCP_PROTOCOL_VERSION_HEADER] const sessionId = request.headers[MCP_SESSION_ID_HEADER] - const session = sessionId === undefined - ? undefined - : state.sessions.bySessionId.get(sessionId) - if (sessionId !== undefined && session === undefined) { - return Effect.succeed(HttpServerResponse.empty({ status: 404 })) - } - if ( - protocolVersion !== undefined && - !state.protocolRegistry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion) - ) { - return Effect.succeed(HttpServerResponse.empty({ status: 400 })) - } - if ( - session?.protocol.transport.requiresVersionHeader === true && - protocolVersion !== session.protocol.protocolVersion - ) { - return Effect.succeed(HttpServerResponse.empty({ status: 400 })) - } return request.text.pipe( Effect.matchEffect({ onFailure: () => @@ -1417,13 +1395,45 @@ const layerMcpProtocolHttp = (options: { error: new InvalidRequest({ message: "Invalid Request" }) })) } + const admission = runtime.admitHttp(request.headers, input) + if (admission._tag === "Rejected") { + return Effect.succeed( + admission.error === undefined + ? HttpServerResponse.empty({ status: admission.status }) + : HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id, + error: admission.error + }, { status: admission.status }) + ) + } const isInitialize = isInitializeJsonRpcMessage(input) if (isInitialize && sessionId !== undefined) { return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } - if (!isInitialize && isRequest && sessionId === undefined) { + if ( + !isInitialize && + isRequest && + admission.protocol?.runtime._tag !== "Stateless" && + sessionId === undefined + ) { return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } + if ( + isRequest && + admission.protocol?.runtime._tag === "Stateless" && + !( + (admission.protocol as unknown as McpProtocolInternal.ProtocolAdapter).handlerRpcs?.requests.has( + input.method as string + ) ?? admission.protocol.clientRpcs.requests.has(input.method as string) + ) + ) { + return Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id, + error: new MethodNotFound({ message: `Method not found: ${input.method}` }) + }, { status: 404 })) + } return httpEffect } if (input.length === 0) { @@ -1433,11 +1443,16 @@ const layerMcpProtocolHttp = (options: { error: new InvalidRequest({ message: "Invalid Request" }) }, { status: 400 })) } - if (input.some(isInitializeJsonRpcMessage) || session === undefined) { + const admission = runtime.admitHttp(request.headers, input) + if ( + admission._tag === "Rejected" || + input.some(isInitializeJsonRpcMessage) || + admission.binding === undefined + ) { return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } - const selectedProtocol = session.protocol - return selectedProtocol.transport.acceptsJsonRpcBatches + const selectedProtocol = admission.binding.protocol + return selectedProtocol.runtime.transport.jsonRpc.acceptsBatches ? httpEffect : Effect.succeed(HttpServerResponse.empty({ status: 400 })) } @@ -1510,7 +1525,7 @@ export const registerToolkit: >( ) => Effect.Effect< void, never, - McpServer | Tool.HandlersFor | Exclude, McpServerClient> + McpServer | Tool.HandlersFor | Exclude, McpRequestContext | McpServerClient> > = Effect.fnUntraced(function*>( toolkit: Toolkit.Toolkit ) { @@ -1518,7 +1533,7 @@ export const registerToolkit: >( const built = yield* (toolkit as any as Effect.Effect< Toolkit.WithHandler, never, - Exclude, McpServerClient> + Exclude, McpRequestContext | McpServerClient> >) const services = yield* Effect.context() for (const tool of Object.values(built.tools)) { @@ -1604,7 +1619,7 @@ export const toolkit = >( ): Layer.Layer< never, never, - Tool.HandlersFor | Exclude, McpServerClient> + Tool.HandlersFor | Exclude, McpRequestContext | McpServerClient> > => Layer.effectDiscard(registerToolkit(toolkit)).pipe( Layer.provide(McpServer.layer) @@ -1677,7 +1692,7 @@ export const registerResource: { R > readonly annotations?: Context.Context | undefined - }): Effect.Effect | McpServer> + }): Effect.Effect | McpServer> >(segments: TemplateStringsArray, ...schemas: Schemas): < E, R, @@ -1705,7 +1720,7 @@ export const registerResource: { | (Completions[keyof Completions] extends (input: string) => infer Ret ? Ret extends Effect.Effect ? _R : never : never), - McpServerClient + McpRequestContext | McpServerClient > | McpServer > @@ -1855,7 +1870,7 @@ export const resource: { E, R > - }): Layer.Layer> + }): Layer.Layer> >(segments: TemplateStringsArray, ...schemas: Schemas): < E, R, @@ -1880,7 +1895,7 @@ export const resource: { | (Completions[keyof Completions] extends (input: string) => infer Ret ? Ret extends Effect.Effect ? _R : never : never), - McpServerClient + McpRequestContext | McpServerClient > > } = function() { @@ -1934,7 +1949,11 @@ export const registerPrompt = < readonly content: (params: Params) => Effect.Effect | string, E, R> readonly annotations?: Context.Context | undefined } -): Effect.Effect | R, McpServerClient> | McpServer> => { +): Effect.Effect< + void, + never, + Exclude | R, McpRequestContext | McpServerClient> | McpServer +> => { const args = Arr.empty() const props: Record = options.parameters ?? {} for (const [name, prop] of Object.entries(props)) { @@ -1961,13 +1980,15 @@ export const registerPrompt = < > = options.completion ?? {} return Effect.gen(function*() { const registry = yield* McpServer - const services = yield* Effect.context, McpServerClient>>() + const services = yield* Effect.context< + Exclude, McpRequestContext | McpServerClient> + >() const completions: Record< string, ( input: string, context: CompletionContext - ) => Effect.Effect + ) => Effect.Effect > = Object.create(null) for (const [param, handle] of Object.entries(completion)) { const encodeArray = Schema.encodeEffect(Schema.Array(props[param])) @@ -2061,7 +2082,11 @@ export const prompt = < ) => Effect.Effect | string, E, R> readonly annotations?: Context.Context | undefined } -): Layer.Layer | R, McpServerClient>> => +): Layer.Layer< + never, + never, + Exclude | R, McpRequestContext | McpServerClient> +> => Layer.effectDiscard(registerPrompt(options)).pipe( Layer.provide(McpServer.layer) ) @@ -2119,8 +2144,8 @@ export const elicit: export const clientCapabilities: Effect.Effect< ClientCapabilities, never, - McpServerClient -> = McpServerClient.useSync((_) => _.clientCapabilities) + McpRequestContext +> = McpRequestContext.useSync((_) => _.clientCapabilities) // ----------------------------------------------------------------------------- // Internal @@ -2168,152 +2193,6 @@ const compileUriTemplate = (segments: TemplateStringsArray, ...schemas: Readonly } as const } -const PingRpcs = RpcGroup.make(Ping).middleware(McpServerClientMiddleware) -const layerHandlers = (serverInfo: { - readonly name: string - readonly version: string - readonly description?: string | undefined - readonly websiteUrl?: string | undefined - readonly icons?: ReadonlyArray | undefined - readonly extensions?: ServerExtensions | undefined -}, options: { - readonly sessions: Sessions - readonly protocolRegistry: McpProtocolRegistry.ProtocolRegistry -}) => - Layer.effectContext( - Effect.gen(function*() { - const server = yield* McpServer - const defaultLogLevel = yield* CurrentLogLevel - const contextMap = new Map() - const internalCore = internalState.get(server)!.core - const handlerTarget = options.protocolRegistry.handlerTarget(contextMap) - - for (const protocol of options.protocolRegistry.protocols) { - const wireHandlers = PingRpcs.of({ - // Requests - ping: () => Effect.succeed({}) - }) - yield* handlerTarget.install(protocol, PingRpcs, wireHandlers) - const lifecycle: McpProtocolInternal.LifecycleRuntime = { - initialize: Effect.fnUntraced( - function*(protocolVersion, profile, clientId) { - const presence = yield* internalCore.registrationPresence - let capabilities: McpCore.CanonicalServerCapabilities = { - completions: true, - logging: true - } - if (presence.tools) { - capabilities = { ...capabilities, tools: { listChanged: true } } - } - if (presence.resources) { - capabilities = { - ...capabilities, - resources: { - listChanged: true, - subscribe: true - } - } - } - if (presence.prompts) { - capabilities = { ...capabilities, prompts: { listChanged: true } } - } - if (serverInfo.extensions) { - capabilities = { - ...capabilities, - extensions: serverInfo.extensions - } - } - return yield* Effect.withFiber((fiber) => { - const httpRequest = Context.getOrUndefined(fiber.context, HttpServerRequest.HttpServerRequest) - if (httpRequest !== undefined && capabilities.resources !== undefined) { - capabilities = { - ...capabilities, - resources: { ...capabilities.resources, subscribe: false } - } - } - const initializePayload = Initialize.payloadSchema.make({ - protocolVersion, - capabilities: profile.clientCapabilities, - clientInfo: profile.clientInfo, - _meta: profile.requestMetadata - }) - const session: Session = { - initializePayload, - negotiatedProfile: profile, - protocol, - resourceSubscriptions: httpRequest === undefined && capabilities.resources?.subscribe === true - ? new Set() - : undefined, - logLevel: { _tag: "Effect", level: defaultLogLevel } - } - if (httpRequest) { - const sessionId = crypto.randomUUID() - options.sessions.bySessionId.set(sessionId, session) - appendPreResponseHandlerUnsafe(httpRequest, (_req, res) => - Effect.succeed(HttpServerResponse.setHeaders(res, { - [MCP_SESSION_ID_HEADER]: sessionId, - [MCP_PROTOCOL_VERSION_HEADER]: protocol.protocolVersion - }))) - } else { - options.sessions.byClientId.set(clientId, session) - } - return Effect.succeed({ - capabilities, - serverInfo: McpSchema.Implementation.make({ - name: serverInfo.name, - version: serverInfo.version, - description: serverInfo.description, - websiteUrl: serverInfo.websiteUrl, - icons: serverInfo.icons - }) - }) - }) - } - ), - setLogLevel: Effect.fnUntraced(function*(level, clientId, headers) { - const session = getClientSession(options.sessions, clientId, headers) - if (session === undefined) { - return - } - session.logLevel = { _tag: "Mcp", level } - }), - subscribe: Effect.fnUntraced(function*(uri, clientId, headers) { - const subscriptions = getClientSession(options.sessions, clientId, headers)?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new McpProtocolInternal.ProtocolError({ - code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE, - message: "Resource subscriptions are not supported" - }) - } - subscriptions.add(uri) - }), - unsubscribe: Effect.fnUntraced(function*(uri, clientId, headers) { - const subscriptions = getClientSession(options.sessions, clientId, headers)?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new McpProtocolInternal.ProtocolError({ - code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE, - message: "Resource subscriptions are not supported" - }) - } - subscriptions.delete(uri) - }), - clientNotification: Effect.fnUntraced(function*(notification, clientId) { - if (notification._tag === "Initialized") { - server.initializedClients.add(clientId) - } - return - }) - } - yield* protocol.installHandlers( - internalCore, - lifecycle, - handlerTarget - ) - } - return Context.makeUnsafe(contextMap) - }) - ) - const resolveResourceContent = ( uri: string, content: typeof ReadResourceResult.Type | string | Uint8Array @@ -2336,18 +2215,6 @@ const resolveResourceContent = ( return content } -const getClientSession = ( - sessions: Sessions, - clientId: number, - headers: Headers.Headers -) => { - const sessionId = headers[MCP_SESSION_ID_HEADER] - if (sessionId === undefined) { - return sessions.byClientId.get(clientId) - } - return sessions.bySessionId.get(sessionId) -} - const InvalidBatchExit = Schema.Struct({ _tag: Schema.Literal("Exit"), requestId: Schema.Null, @@ -2359,61 +2226,17 @@ const InvalidBatchExit = Schema.Struct({ const decodeInvalidBatchExit = Schema.decodeUnknownResult(InvalidBatchExit) -const mcpLogLevels: Record = { - debug: { effect: "Debug", order: 0 }, - info: { effect: "Info", order: 1 }, - notice: { effect: "Info", order: 2 }, - warning: { effect: "Warn", order: 3 }, - error: { effect: "Error", order: 4 }, - critical: { effect: "Fatal", order: 5 }, - alert: { effect: "Fatal", order: 6 }, - emergency: { effect: "Fatal", order: 7 } -} - -const effectLogLevel = (logLevel: SessionLogLevel | undefined, fallback: LogLevel.LogLevel): LogLevel.LogLevel => - logLevel?._tag === "Mcp" ? mcpLogLevels[logLevel.level].effect : logLevel?.level ?? fallback - -const isMcpLogLevelEnabled = ( - level: McpSchema.LoggingLevel, - minimum: SessionLogLevel | undefined, - fallback: LogLevel.LogLevel -): boolean => - minimum?._tag === "Mcp" - ? mcpLogLevels[level].order >= mcpLogLevels[minimum.level].order - : LogLevel.isGreaterThanOrEqualTo(mcpLogLevels[level].effect, minimum?.level ?? fallback) - -const OfferedProtocolVersion = Schema.Struct({ - protocolVersion: Schema.String -}) - -const getOfferedProtocolVersion = (payload: unknown): string => { - const decoded = Schema.decodeUnknownResult(OfferedProtocolVersion)(payload) - return Result.isSuccess(decoded) ? decoded.success.protocolVersion : "" -} - -const protocolForInternalTag = ( - registry: McpProtocolRegistry.ProtocolRegistry, - tag: string -): McpProtocol.ProtocolAdapter => { - for (const protocol of registry.protocols) { - const routed = registry.routeClientRequest(protocol, { - _tag: "Request", - id: 0, - tag: "", - payload: undefined, - headers: [] - }) - if (tag.startsWith(routed.tag)) { - return protocol - } - } - return registry.protocols[0] -} - const getProtocolForClient = ( - clientProtocols: Map, + clientProtocols: Map, clientId: number, - registry: McpProtocolRegistry.ProtocolRegistry -): McpProtocol.ProtocolAdapter => + fallback: McpProtocol.AnyProtocolAdapter +): McpProtocol.AnyProtocolAdapter => clientProtocols.get(clientId) ?? - registry.protocols[0] + fallback + +const isProtocolVersion = (version: string): version is McpProtocol.ProtocolVersion => + version === "2024-11-05" || + version === "2025-03-26" || + version === "2025-06-18" || + version === "2025-11-25" || + version === "2026-07-28" diff --git a/packages/effect/src/unstable/ai/internal/mcpCore.ts b/packages/effect/src/unstable/ai/internal/mcpCore.ts index 571efbfa917..001e6b8ae9d 100644 --- a/packages/effect/src/unstable/ai/internal/mcpCore.ts +++ b/packages/effect/src/unstable/ai/internal/mcpCore.ts @@ -23,8 +23,12 @@ export interface NegotiatedProtocolProfile< // Core decisions receive negotiated facts rather than dated wire requests. readonly protocolVersion: Version readonly clientCapabilities: McpSchema.ClientCapabilities - readonly clientInfo: McpSchema.Implementation - readonly requestMetadata?: CanonicalRequestMetadata | undefined + readonly clientInfo: Version extends McpProtocol.ProtocolVersion ? McpSchema.Implementation + : McpSchema.Implementation | undefined + readonly requestMetadata?: + | (Version extends McpProtocol.ProtocolVersion ? CanonicalRequestMetadata + : CanonicalRequestMetadata | Schema.JsonObject) + | undefined } // NOTE: Capabilities remain a normalized core model because dated revisions @@ -55,8 +59,9 @@ export interface CanonicalInitializeResult { /** @internal */ export interface McpInvocation { readonly clientId: number - readonly protocol: NegotiatedProtocolProfile - readonly requestContext: McpSchema.McpServerClient["Service"] + readonly protocol: NegotiatedProtocolProfile + readonly requestContext: McpSchema.McpRequestContext["Service"] + readonly serverClient?: McpSchema.McpServerClient["Service"] | undefined } // NOTE: McpInvocation is runtime context, not a wire DTO. It combines the @@ -111,7 +116,7 @@ export interface ToolRegistration { // The canonical Tool copy normalizes its top-level title from // `tool.title ?? tool.annotations?.title` at the public boundary. readonly descriptor: McpSchema.Tool - readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean readonly handle: ( call: typeof McpSchema.CallTool.payloadSchema.Type, invocation: McpInvocation @@ -128,7 +133,7 @@ export interface Tools { registration: ToolRegistration ) => Effect.Effect readonly list: ( - profile: NegotiatedProtocolProfile + profile: NegotiatedProtocolProfile ) => Effect.Effect> readonly call: ( call: typeof McpSchema.CallTool.payloadSchema.Type, @@ -139,7 +144,7 @@ export interface Tools { /** @internal */ export interface ResourceRegistration { readonly descriptor: McpSchema.Resource - readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean readonly read: ( invocation: McpInvocation ) => Effect.Effect @@ -148,7 +153,7 @@ export interface ResourceRegistration { /** @internal */ export interface ResourceTemplateRegistration { readonly descriptor: McpSchema.ResourceTemplate - readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean readonly match: (uri: string) => ReadonlyArray | undefined readonly read: ( uri: string, @@ -162,10 +167,10 @@ export interface Resources { readonly register: (registration: ResourceRegistration) => Effect.Effect readonly registerTemplate: (registration: ResourceTemplateRegistration) => Effect.Effect readonly list: ( - profile: NegotiatedProtocolProfile + profile: NegotiatedProtocolProfile ) => Effect.Effect> readonly listTemplates: ( - profile: NegotiatedProtocolProfile + profile: NegotiatedProtocolProfile ) => Effect.Effect> readonly read: ( uri: string, @@ -184,7 +189,7 @@ export class PromptNotFound extends Data.TaggedError("PromptNotFound")<{ /** @internal */ export interface PromptRegistration { readonly descriptor: McpSchema.Prompt - readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean readonly get: ( args: Readonly>, invocation: McpInvocation @@ -195,7 +200,7 @@ export interface PromptRegistration { export interface Prompts { readonly register: (registration: PromptRegistration) => Effect.Effect readonly list: ( - profile: NegotiatedProtocolProfile + profile: NegotiatedProtocolProfile ) => Effect.Effect> readonly get: ( name: string, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index 7a88b015a1c..e8f08e54e38 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -29,6 +29,27 @@ export const invocationFromClient = ( ): McpCore.McpInvocation => ({ clientId: request.clientId, protocol: profileFromClient(request), + requestContext: PublicMcpSchema.McpRequestContext.of({ + clientId: request.clientId, + protocolVersion: request.protocolVersion, + clientCapabilities: request.clientCapabilities, + clientInfo: request.clientInfo, + requestMetadata: request.initializePayload._meta + }), + serverClient: request +}) + +/** @internal */ +export const invocationFromRequestContext = ( + request: PublicMcpSchema.McpRequestContext["Service"] +): McpCore.McpInvocation => ({ + clientId: request.clientId, + protocol: { + protocolVersion: request.protocolVersion, + clientCapabilities: request.clientCapabilities, + clientInfo: request.clientInfo, + requestMetadata: request.requestMetadata + }, requestContext: request }) @@ -205,6 +226,7 @@ export const makeNotificationProjector = Effect.fn(function*( /** @internal */ export interface HandlerInstallationTarget { + readonly context: HandlerInstallationContext readonly install: < Rpcs extends Rpc.Any, Handlers extends RpcGroup.HandlersFrom @@ -217,9 +239,27 @@ export interface HandlerInstallationTarget { ) => Effect.Effect> } +/** @internal */ +export interface HandlerInstallationContext { + readonly supportedVersions: ReadonlyArray + readonly serverInfo: { + readonly name: string + readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + } + readonly registrationPresence: { + readonly tools: boolean + readonly resources: boolean + readonly prompts: boolean + } +} + /** @internal */ export interface ProtocolAdapter< out Version extends string = string, + out Runtime extends PublicMcpProtocol.RuntimeDescriptor = PublicMcpProtocol.RuntimeDescriptor, ClientRpcs extends Rpc.Any = Rpc.Any, ClientNotificationRpcs extends ClientRpcs = ClientRpcs, ServerRequestRpcs extends Rpc.Any = Rpc.Any, @@ -230,10 +270,7 @@ export interface ProtocolAdapter< // Each adapter owns its dated RPC vocabulary, transport policy, handler // projection, and wire behavior. readonly protocolVersion: Version - readonly transport: { - readonly acceptsJsonRpcBatches: boolean - readonly requiresVersionHeader: boolean - } + readonly runtime: Runtime readonly clientRpcs: RpcGroup.RpcGroup readonly clientNotificationRpcs: RpcGroup.RpcGroup readonly serverRequestRpcs: RpcGroup.RpcGroup @@ -242,7 +279,7 @@ export interface ProtocolAdapter< readonly handlerRpcs?: RpcGroup.RpcGroup | undefined readonly installHandlers: ( core: McpCore.McpCore, - lifecycle: LifecycleRuntime, + lifecycle: LifecycleRuntime | undefined, target: HandlerInstallationTarget ) => Effect.Effect readonly makeReverseClient: ( @@ -263,6 +300,7 @@ export interface ProtocolAdapter< /** @internal */ export const make = < const Version extends string, + const Runtime extends PublicMcpProtocol.RuntimeDescriptor, ClientRpcs extends Rpc.Any, ClientNotificationRpcs extends ClientRpcs, ServerRequestRpcs extends Rpc.Any, @@ -271,10 +309,7 @@ export const make = < Handlers extends RpcGroup.HandlersFrom = RpcGroup.HandlersFrom >(options: { readonly protocolVersion: Version - readonly transport: { - readonly acceptsJsonRpcBatches: boolean - readonly requiresVersionHeader: boolean - } + readonly runtime: Runtime readonly clientRpcs: RpcGroup.RpcGroup readonly clientNotificationRpcs: RpcGroup.RpcGroup readonly serverRequestRpcs: RpcGroup.RpcGroup @@ -283,7 +318,8 @@ export const make = < readonly makeHandlers?: | (( core: McpCore.McpCore, - lifecycle: LifecycleRuntime + lifecycle: LifecycleRuntime, + context: HandlerInstallationContext ) => Handlers) | undefined readonly toReverseClient: ( @@ -298,6 +334,7 @@ export const make = < ) => Effect.Effect }): ProtocolAdapter< Version, + Runtime, ClientRpcs, ClientNotificationRpcs, ServerRequestRpcs, @@ -321,12 +358,18 @@ export const make = < const installHandlers = ( core: McpCore.McpCore, - lifecycle: LifecycleRuntime, + lifecycle: LifecycleRuntime | undefined, target: HandlerInstallationTarget ): Effect.Effect> => options.handlerRpcs === undefined || options.makeHandlers === undefined ? Effect.void - : target.install(options, options.handlerRpcs, options.makeHandlers(core, lifecycle)) + : lifecycle === undefined && options.runtime._tag === "Stateful" + ? Effect.die("MCP sessionful handler installation requires a lifecycle runtime") + : target.install( + options, + options.handlerRpcs, + options.makeHandlers(core, lifecycle!, target.context) + ) const makeReverseClient = ( profile: McpCore.NegotiatedProtocolProfile diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts index a0224ff4a3c..6de378ac13e 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts @@ -6,6 +6,7 @@ import * as Schema from "../../../../Schema.ts" import * as PublicMcpSchema from "../../McpSchema.ts" import * as McpCore from "../mcpCore.ts" import * as McpProtocol from "../mcpProtocol.ts" +import * as McpRuntime from "../mcpRuntime.ts" import * as McpSchema from "../mcpSchema/v2024_11_05.ts" const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( @@ -106,10 +107,10 @@ const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSche /** @internal */ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: false - }, + runtime: McpRuntime.stateful({ + jsonRpc: { acceptsBatches: false }, + http: { requiresVersionHeader: false } + }), clientRpcs: ClientRpcs, clientNotificationRpcs: McpSchema.ClientNotificationRpcs, serverRequestRpcs: McpSchema.ServerRequestRpcs, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts index 66dd94ecdeb..039c7d31596 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts @@ -6,6 +6,7 @@ import * as Schema from "../../../../Schema.ts" import * as PublicMcpSchema from "../../McpSchema.ts" import * as McpCore from "../mcpCore.ts" import * as McpProtocol from "../mcpProtocol.ts" +import * as McpRuntime from "../mcpRuntime.ts" import * as McpSchema from "../mcpSchema/v2025_03_26.ts" const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( @@ -110,10 +111,10 @@ const projectResourceContents = ( /** @internal */ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, - transport: { - acceptsJsonRpcBatches: true, - requiresVersionHeader: false - }, + runtime: McpRuntime.stateful({ + jsonRpc: { acceptsBatches: true }, + http: { requiresVersionHeader: false } + }), clientRpcs: ClientRpcs, clientNotificationRpcs: McpSchema.ClientNotificationRpcs, serverRequestRpcs: McpSchema.ServerRequestRpcs, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts index 1420b635fad..fb70aa1ce10 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -6,6 +6,7 @@ import * as Struct from "../../../../Struct.ts" import * as PublicMcpSchema from "../../McpSchema.ts" import * as McpCore from "../mcpCore.ts" import * as McpProtocol from "../mcpProtocol.ts" +import * as McpRuntime from "../mcpRuntime.ts" import * as McpSchema from "../mcpSchema/v2025_06_18.ts" const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( @@ -124,10 +125,10 @@ const isJsonObject = (value: Schema.Json): value is Schema.JsonObject => /** @internal */ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: true - }, + runtime: McpRuntime.stateful({ + jsonRpc: { acceptsBatches: false }, + http: { requiresVersionHeader: true } + }), clientRpcs: ClientRpcs, clientNotificationRpcs: McpSchema.ClientNotificationRpcs, serverRequestRpcs: McpSchema.ServerRequestRpcs, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts index 7033db4664d..d56f31c9873 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts @@ -5,6 +5,7 @@ import * as Schema from "../../../../Schema.ts" import * as PublicMcpSchema from "../../McpSchema.ts" import * as McpCore from "../mcpCore.ts" import * as McpProtocol from "../mcpProtocol.ts" +import * as McpRuntime from "../mcpRuntime.ts" import * as McpSchema from "../mcpSchema/v2025_11_25.ts" const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( @@ -116,10 +117,10 @@ const projectStructuredContent = Effect.fnUntraced(function*(content: Schema.Jso /** @internal */ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: true - }, + runtime: McpRuntime.stateful({ + jsonRpc: { acceptsBatches: false }, + http: { requiresVersionHeader: true } + }), clientRpcs: ClientRpcs, clientNotificationRpcs: McpSchema.ClientNotificationRpcs, serverRequestRpcs: McpSchema.ServerRequestRpcs, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts b/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts index 0f6762d145e..a5ec9a29b77 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts @@ -25,7 +25,8 @@ export interface ProtocolRegistry< request: RpcMessage.RequestEncoded ) => RpcMessage.RequestEncoded readonly handlerTarget: ( - contextMap: Map + contextMap: Map, + context: McpProtocolInternal.HandlerInstallationContext ) => McpProtocolInternal.HandlerInstallationTarget } @@ -72,7 +73,8 @@ export const make = Effect.fnUntraced(function*< ...request, tag: `${prefix(protocol)}${request.tag}` }), - handlerTarget: (contextMap: Map): McpProtocolInternal.HandlerInstallationTarget => ({ + handlerTarget: (contextMap, context): McpProtocolInternal.HandlerInstallationTarget => ({ + context, install: Effect.fnUntraced(function*< Rpcs extends Rpc.Any, Handlers extends RpcGroup.HandlersFrom diff --git a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts new file mode 100644 index 00000000000..6f70aa4075b --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts @@ -0,0 +1,444 @@ +/** + * Runtime descriptors for MCP protocol adapters. + * + * @internal + */ +import type { NonEmptyReadonlyArray } from "../../../Array.ts" +import * as Cause from "../../../Cause.ts" +import * as Context from "../../../Context.ts" +import * as Effect from "../../../Effect.ts" +import * as Encoding from "../../../Encoding.ts" +import * as Layer from "../../../Layer.ts" +import type * as LogLevel from "../../../LogLevel.ts" +import * as Result from "../../../Result.ts" +import type * as Headers from "../../http/Headers.ts" +import { appendPreResponseHandlerUnsafe } from "../../http/HttpEffect.ts" +import * as HttpServerRequest from "../../http/HttpServerRequest.ts" +import * as HttpServerResponse from "../../http/HttpServerResponse.ts" +import * as RpcGroup from "../../rpc/RpcGroup.ts" +import type * as RpcMessage from "../../rpc/RpcMessage.ts" +import type * as PublicMcpProtocol from "../McpProtocol.ts" +import * as PublicMcpSchema from "../McpSchema.ts" +import type * as McpCore from "./mcpCore.ts" +import type * as McpProtocol from "./mcpProtocol.ts" +import * as McpProtocolRegistry from "./mcpProtocolRegistry.ts" +import * as McpStatefulRuntime from "./mcpStatefulRuntime.ts" + +const MCP_SESSION_ID_HEADER = "mcp-session-id" +const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" +const MCP_METHOD_HEADER = "mcp-method" +const MCP_NAME_HEADER = "mcp-name" +const PROTOCOL_VERSION_METADATA_KEY = "io.modelcontextprotocol/protocolVersion" +const BASE64_SENTINEL_PREFIX = "=?base64?" +const BASE64_SENTINEL_SUFFIX = "?=" + +const asRecord = (input: unknown): Record | undefined => + typeof input === "object" && input !== null ? input as Record : undefined + +const modernVersionClaim = (input: unknown): { readonly present: boolean; readonly value: unknown } => { + const params = asRecord(asRecord(input)?.params) + const metadata = asRecord(params?._meta) + return metadata !== undefined && PROTOCOL_VERSION_METADATA_KEY in metadata + ? { present: true, value: metadata[PROTOCOL_VERSION_METADATA_KEY] } + : { present: false, value: undefined } +} + +const decodeRoutingHeader = (value: string): string | undefined => { + const startsWithSentinel = value.startsWith(BASE64_SENTINEL_PREFIX) + const endsWithSentinel = value.endsWith(BASE64_SENTINEL_SUFFIX) + if (!startsWithSentinel && !endsWithSentinel) { + return /^[\x20-\x7e]*$/.test(value) ? value : undefined + } + if (!startsWithSentinel || !endsWithSentinel) { + return undefined + } + const encoded = value.slice(BASE64_SENTINEL_PREFIX.length, -BASE64_SENTINEL_SUFFIX.length) + const decoded = Encoding.decodeBase64String(encoded) + return Result.isSuccess(decoded) ? decoded.success : undefined +} + +const routingName = (input: unknown): string | undefined => { + const request = asRecord(input) + const params = asRecord(request?.params) + switch (request?.method) { + case "tools/call": + case "prompts/get": + return typeof params?.name === "string" ? params.name : undefined + case "resources/read": + return typeof params?.uri === "string" ? params.uri : undefined + default: + return undefined + } +} + +const requiresRoutingName = (method: unknown): boolean => + method === "tools/call" || method === "resources/read" || method === "prompts/get" + +const headerMismatch = (message: string): HttpAdmission => ({ + _tag: "Rejected", + status: 400, + error: { code: -32020, message } +}) + +const PingRpcs = RpcGroup.make(PublicMcpSchema.Ping).middleware(PublicMcpSchema.McpServerClientMiddleware) + +/** @internal */ +export interface RequestBinding { + readonly initializePayload: typeof PublicMcpSchema.Initialize.payloadSchema.Type + readonly negotiatedProfile: McpCore.NegotiatedProtocolProfile + readonly protocol: PublicMcpProtocol.AnyProtocolAdapter +} + +/** @internal */ +export interface PreparedRequest { + readonly protocol: PublicMcpProtocol.AnyProtocolAdapter + readonly binding?: RequestBinding | undefined + readonly profile?: McpCore.NegotiatedProtocolProfile | undefined + readonly requestContext?: PublicMcpSchema.McpRequestContext["Service"] | undefined +} + +/** @internal */ +export type HttpAdmission = + | { + readonly _tag: "Accepted" + readonly binding: RequestBinding | undefined + readonly protocol?: PublicMcpProtocol.AnyProtocolAdapter | undefined + } + | { + readonly _tag: "Rejected" + readonly status: 400 | 404 + readonly error?: { + readonly code: number + readonly message: string + readonly data?: unknown + } | undefined + } + +/** @internal */ +export interface HandlerInstallationOptions { + readonly core: McpCore.McpCore + readonly defaultLogLevel: LogLevel.LogLevel + readonly serverInfo: { + readonly name: string + readonly version: string + readonly description?: string | undefined + readonly websiteUrl?: string | undefined + readonly icons?: ReadonlyArray | undefined + readonly extensions?: NonNullable | undefined + } +} + +/** @internal */ +export const stateful = ( + transport: PublicMcpProtocol.TransportPolicy +): PublicMcpProtocol.StatefulRuntimeDescriptor => ({ + _tag: "Stateful", + transport +}) + +/** @internal */ +export interface ServerRuntimeShape { + readonly protocols: NonEmptyReadonlyArray + readonly clientRpcs: McpProtocolRegistry.ProtocolRegistry["clientRpcs"] + readonly selectProtocol: (offeredVersion: string) => PublicMcpProtocol.AnyProtocolAdapter + readonly protocolForInternalTag: (tag: string) => PublicMcpProtocol.AnyProtocolAdapter + readonly routeClientRequest: ( + protocol: PublicMcpProtocol.AnyProtocolAdapter, + request: RpcMessage.RequestEncoded + ) => RpcMessage.RequestEncoded + readonly prepareRequest: ( + clientId: number, + headers: Headers.Headers, + request: RpcMessage.RequestEncoded + ) => Effect.Effect + readonly resolveRequest: (clientId: number, headers: Headers.Headers) => RequestBinding | undefined + readonly admitHttp: (headers: Headers.Headers, input: unknown) => HttpAdmission + readonly effectLogLevel: ( + clientId: number, + headers: Headers.Headers, + fallback: LogLevel.LogLevel + ) => LogLevel.LogLevel + readonly disconnect: (clientId: number) => void + readonly deliveryClientIds: () => Iterable + readonly canDeliver: ( + clientId: number, + headers: Headers.Headers, + notification: McpCore.ServerNotification, + fallbackLogLevel: LogLevel.LogLevel + ) => boolean + readonly installHandlers: ( + options: HandlerInstallationOptions + ) => Effect.Effect, never, unknown> +} + +/** @internal */ +export class ServerRuntime extends Context.Service()( + "effect/ai/McpRuntime/ServerRuntime" +) {} + +/** @internal */ +export const make = Effect.fnUntraced(function*( + protocols: NonEmptyReadonlyArray +) { + const statefulProtocol = protocols.find((protocol) => protocol.runtime._tag === "Stateful") + const stateful = statefulProtocol === undefined ? undefined : McpStatefulRuntime.make() + const protocolVersions = protocols.map((protocol) => protocol.protocolVersion) + let statelessDescriptor: PublicMcpProtocol.StatelessRuntimeDescriptor | undefined + let statelessProtocol: PublicMcpProtocol.AnyProtocolAdapter | undefined + for (const protocol of protocols) { + if (protocol.runtime._tag !== "Stateless") { + continue + } + if (statelessDescriptor !== undefined) { + return yield* new Cause.IllegalArgumentError( + "MCP runtime supports at most one stateless protocol" + ) + } + statelessDescriptor = protocol.runtime + statelessProtocol = protocol + } + const registry = yield* McpProtocolRegistry.make(protocols) + const protocolForInternalTag = (tag: string): PublicMcpProtocol.AnyProtocolAdapter => { + for (const protocol of registry.protocols) { + const routed = registry.routeClientRequest(protocol, { + _tag: "Request", + id: 0, + tag: "", + payload: undefined, + headers: [] + }) + if (tag.startsWith(routed.tag)) { + return protocol + } + } + return registry.protocols[0] + } + return ServerRuntime.of({ + protocols: registry.protocols, + clientRpcs: registry.clientRpcs, + selectProtocol: registry.select, + protocolForInternalTag, + routeClientRequest: registry.routeClientRequest, + prepareRequest: Effect.fnUntraced(function*(clientId, headers, request) { + const binding = stateful?.resolve(clientId, headers) + let protocol: PublicMcpProtocol.AnyProtocolAdapter | undefined = binding?.protocol + if (protocol === undefined) { + const metadata = typeof request.payload === "object" && request.payload !== null && "_meta" in request.payload + ? request.payload._meta + : undefined + const hasStatelessVersion = typeof metadata === "object" && metadata !== null && + "io.modelcontextprotocol/protocolVersion" in metadata + const offeredVersion = hasStatelessVersion && + typeof metadata["io.modelcontextprotocol/protocolVersion"] === "string" + ? metadata["io.modelcontextprotocol/protocolVersion"] + : undefined + protocol = hasStatelessVersion + ? registry.protocols.find((protocol) => protocol.protocolVersion === offeredVersion) ?? statelessProtocol ?? + registry.protocols[0] + : offeredVersion === undefined + ? request.tag === "initialize" + ? registry.select((request.payload as any)?.protocolVersion) + : registry.protocols[0] + : registry.select(offeredVersion) + } + if (protocol.runtime._tag === "Stateful") { + return { protocol, binding } + } + if (statelessDescriptor === undefined) { + return yield* Effect.die("MCP stateless runtime invariant failed") + } + const metadata = typeof request.payload === "object" && request.payload !== null && "_meta" in request.payload + ? request.payload._meta + : undefined + const decodedProfile = yield* statelessDescriptor.profileFromRequestMetadata(metadata) + const profile: McpCore.NegotiatedProtocolProfile = { + protocolVersion: decodedProfile.protocolVersion, + clientCapabilities: decodedProfile.clientCapabilities, + clientInfo: decodedProfile.clientInfo, + requestMetadata: decodedProfile.requestMetadata + } + const requestContext = PublicMcpSchema.McpRequestContext.of({ + clientId, + protocolVersion: profile.protocolVersion, + clientCapabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + requestMetadata: profile.requestMetadata + }) + return { protocol, profile, requestContext } + }), + resolveRequest: (clientId, headers) => stateful?.resolve(clientId, headers), + admitHttp: (headers, input) => { + const protocolVersion = headers[MCP_PROTOCOL_VERSION_HEADER] + const sessionId = headers[MCP_SESSION_ID_HEADER] + const claim = modernVersionClaim(input) + const isStatelessRequest = (asRecord(input)?.method !== "initialize" && claim.present) || + (statelessProtocol !== undefined && protocolVersion === statelessProtocol.protocolVersion) + if (isStatelessRequest) { + if (protocolVersion === undefined) { + return headerMismatch("MCP-Protocol-Version header is required") + } + if (typeof claim.value !== "string" || claim.value !== protocolVersion) { + return headerMismatch("MCP-Protocol-Version header does not match request metadata") + } + if (statelessProtocol === undefined || protocolVersion !== statelessProtocol.protocolVersion) { + return { + _tag: "Rejected", + status: 400, + error: { + code: -32022, + message: `Unsupported protocol version '${protocolVersion}'`, + data: { + supported: protocolVersions, + requested: protocolVersion + } + } + } + } + const request = asRecord(input) + const method = request?.method + if (method === "initialize") { + return headerMismatch("initialize is not supported by stateless MCP protocols") + } + if (typeof method !== "string" || headers[MCP_METHOD_HEADER] !== method) { + return headerMismatch("Mcp-Method header does not match request method") + } + if (requiresRoutingName(method)) { + const name = routingName(input) + const header = headers[MCP_NAME_HEADER] + if (name === undefined || header === undefined || decodeRoutingHeader(header) !== name) { + return headerMismatch("Mcp-Name header does not match request parameters") + } + } + 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 ( + protocolVersion !== undefined && + !registry.protocols.some((protocol) => protocol.protocolVersion === protocolVersion) + ) { + return { _tag: "Rejected", status: 400 } + } + if ( + binding?.protocol.runtime.transport.http.requiresVersionHeader === true && + protocolVersion !== binding.protocol.protocolVersion + ) { + return { _tag: "Rejected", status: 400 } + } + return { _tag: "Accepted", binding, protocol: binding?.protocol } + }, + effectLogLevel: (clientId, headers, fallback) => stateful?.effectLogLevel(clientId, headers, fallback) ?? fallback, + disconnect: (clientId) => stateful?.disconnect(clientId), + deliveryClientIds: () => stateful?.initializedClientIds() ?? [], + canDeliver: (clientId, headers, notification, fallback) => + stateful?.canDeliver(clientId, headers, notification, fallback) ?? false, + installHandlers: Effect.fnUntraced(function*(options) { + const contextMap = new Map() + const registrationPresence = yield* options.core.registrationPresence + const installationContext: McpProtocol.HandlerInstallationContext = { + supportedVersions: protocolVersions, + serverInfo: options.serverInfo, + registrationPresence + } + const handlerTarget = registry.handlerTarget(contextMap, installationContext) + for (const protocol of registry.protocols) { + if (protocol.runtime._tag === "Stateless") { + yield* protocol.installHandlers(options.core, undefined, handlerTarget) + continue + } + if (stateful === undefined) { + return yield* Effect.die("MCP sessionful runtime invariant failed") + } + yield* handlerTarget.install(protocol, PingRpcs, PingRpcs.of({ ping: () => Effect.succeed({}) })) + const lifecycle: McpProtocol.LifecycleRuntime = { + initialize: Effect.fnUntraced(function*(protocolVersion, profile, clientId) { + const presence = yield* options.core.registrationPresence + let capabilities: McpCore.CanonicalServerCapabilities = { + completions: true, + logging: true + } + if (presence.tools) { + capabilities = { ...capabilities, tools: { listChanged: true } } + } + if (presence.resources) { + capabilities = { + ...capabilities, + resources: { listChanged: true, subscribe: true } + } + } + if (presence.prompts) { + capabilities = { ...capabilities, prompts: { listChanged: true } } + } + if (options.serverInfo.extensions) { + capabilities = { ...capabilities, extensions: options.serverInfo.extensions } + } + return yield* Effect.withFiber((fiber) => { + const httpRequest = Context.getOrUndefined(fiber.context, HttpServerRequest.HttpServerRequest) + if (httpRequest !== undefined && capabilities.resources !== undefined) { + capabilities = { + ...capabilities, + resources: { ...capabilities.resources, subscribe: false } + } + } + const initializePayload = PublicMcpSchema.Initialize.payloadSchema.make({ + protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo!, + _meta: profile.requestMetadata + }) + const registration: McpStatefulRuntime.Registration = { + initializePayload, + negotiatedProfile: profile, + protocol: protocol as PublicMcpProtocol.ProtocolAdapter, + supportsResourceSubscriptions: httpRequest === undefined && + capabilities.resources?.subscribe === true, + logLevel: options.defaultLogLevel + } + if (httpRequest !== undefined) { + const sessionId = crypto.randomUUID() + stateful.registerHttp(sessionId, registration) + appendPreResponseHandlerUnsafe( + httpRequest, + (_request, response) => + Effect.succeed(HttpServerResponse.setHeaders(response, { + [MCP_SESSION_ID_HEADER]: sessionId, + [MCP_PROTOCOL_VERSION_HEADER]: protocol.protocolVersion + })) + ) + } else { + stateful.registerConnection(clientId, registration) + } + return Effect.succeed({ + capabilities, + serverInfo: PublicMcpSchema.Implementation.make({ + name: options.serverInfo.name, + version: options.serverInfo.version, + description: options.serverInfo.description, + websiteUrl: options.serverInfo.websiteUrl, + icons: options.serverInfo.icons + }) + }) + }) + }), + setLogLevel: stateful.setLogLevel, + subscribe: stateful.subscribe, + unsubscribe: stateful.unsubscribe, + clientNotification: Effect.fnUntraced(function*(notification, clientId) { + if (notification._tag === "Initialized") { + stateful.markInitialized(clientId) + } + }) + } + yield* protocol.installHandlers(options.core, lifecycle, handlerTarget) + } + return Context.makeUnsafe(contextMap) + }) + }) +}) + +/** @internal */ +export const layer = ( + protocols: NonEmptyReadonlyArray +): Layer.Layer => Layer.effect(ServerRuntime)(make(protocols)) diff --git a/packages/effect/src/unstable/ai/internal/mcpStatefulRuntime.ts b/packages/effect/src/unstable/ai/internal/mcpStatefulRuntime.ts new file mode 100644 index 00000000000..756ffd0f569 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpStatefulRuntime.ts @@ -0,0 +1,185 @@ +/** + * Stateful lifecycle storage for MCP revisions before v2026-07-28. + * + * @internal + */ +import * as Effect from "../../../Effect.ts" +import * as LogLevel from "../../../LogLevel.ts" +import type * as Headers from "../../http/Headers.ts" +import type * as PublicMcpProtocol from "../McpProtocol.ts" +import * as PublicMcpSchema from "../McpSchema.ts" +import type * as McpCore from "./mcpCore.ts" +import * as McpProtocol from "./mcpProtocol.ts" + +const MCP_SESSION_ID_HEADER = "mcp-session-id" + +type SessionLogLevel = + | { readonly _tag: "Effect"; readonly level: LogLevel.LogLevel } + | { readonly _tag: "Mcp"; readonly level: PublicMcpSchema.LoggingLevel } + +/** @internal */ +export interface Binding { + readonly initializePayload: typeof PublicMcpSchema.Initialize.payloadSchema.Type + readonly negotiatedProfile: McpCore.NegotiatedProtocolProfile + readonly protocol: PublicMcpProtocol.ProtocolAdapter +} + +interface Session extends Binding { + readonly resourceSubscriptions: Set | undefined + logLevel: SessionLogLevel +} + +/** @internal */ +export interface Registration extends Binding { + readonly supportsResourceSubscriptions: boolean + readonly logLevel: LogLevel.LogLevel +} + +export interface StatefulRuntime { + readonly registerHttp: (sessionId: string, registration: Registration) => Binding + readonly registerConnection: (clientId: number, registration: Registration) => Binding + readonly resolve: (clientId: number, headers: Headers.Headers) => Binding | undefined + readonly resolveSessionId: (sessionId: string) => Binding | undefined + readonly setLogLevel: ( + level: PublicMcpSchema.LoggingLevel, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect + readonly subscribe: ( + uri: string, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect + readonly unsubscribe: ( + uri: string, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect + readonly canDeliver: ( + clientId: number, + headers: Headers.Headers, + notification: McpCore.ServerNotification, + fallbackLogLevel: LogLevel.LogLevel + ) => boolean + readonly effectLogLevel: ( + clientId: number, + headers: Headers.Headers, + fallback: LogLevel.LogLevel + ) => LogLevel.LogLevel + readonly markInitialized: (clientId: number) => void + readonly initializedClientIds: () => Iterable + readonly disconnect: (clientId: number) => void +} + +const mcpLogLevels: Record< + PublicMcpSchema.LoggingLevel, + { readonly effect: LogLevel.LogLevel; readonly order: number } +> = { + debug: { effect: "Debug", order: 0 }, + info: { effect: "Info", order: 1 }, + notice: { effect: "Info", order: 2 }, + warning: { effect: "Warn", order: 3 }, + error: { effect: "Error", order: 4 }, + critical: { effect: "Fatal", order: 5 }, + alert: { effect: "Fatal", order: 6 }, + emergency: { effect: "Fatal", order: 7 } +} + +const makeSession = (registration: Registration): Session => ({ + initializePayload: registration.initializePayload, + negotiatedProfile: registration.negotiatedProfile, + protocol: registration.protocol, + resourceSubscriptions: registration.supportsResourceSubscriptions ? new Set() : undefined, + logLevel: { _tag: "Effect", level: registration.logLevel } +}) + +/** @internal */ +export const make = (): StatefulRuntime => { + const bySessionId = new Map() + const byClientId = new Map() + const initializedClientIds = new Set() + + const resolveSession = (clientId: number, headers: Headers.Headers): Session | undefined => { + const sessionId = headers[MCP_SESSION_ID_HEADER] + return sessionId === undefined ? byClientId.get(clientId) : bySessionId.get(sessionId) + } + + const effectLogLevel = ( + clientId: number, + headers: Headers.Headers, + fallback: LogLevel.LogLevel + ): LogLevel.LogLevel => { + const session = resolveSession(clientId, headers) + return session?.logLevel._tag === "Mcp" + ? mcpLogLevels[session.logLevel.level].effect + : session?.logLevel.level ?? fallback + } + + return { + registerHttp: (sessionId, registration) => { + const session = makeSession(registration) + bySessionId.set(sessionId, session) + return session + }, + registerConnection: (clientId, registration) => { + const session = makeSession(registration) + byClientId.set(clientId, session) + return session + }, + resolve: resolveSession, + resolveSessionId: (sessionId) => bySessionId.get(sessionId), + setLogLevel: (level, clientId, headers) => + Effect.sync(() => { + const session = resolveSession(clientId, headers) + if (session !== undefined) { + session.logLevel = { _tag: "Mcp", level } + } + }), + subscribe: (uri, clientId, headers) => { + const subscriptions = resolveSession(clientId, headers)?.resourceSubscriptions + if (subscriptions === undefined) { + return Effect.fail( + new McpProtocol.ProtocolError({ + code: PublicMcpSchema.METHOD_NOT_FOUND_ERROR_CODE, + message: "Resource subscriptions are not supported" + }) + ) + } + return Effect.sync(() => subscriptions.add(uri)).pipe(Effect.asVoid) + }, + unsubscribe: (uri, clientId, headers) => { + const subscriptions = resolveSession(clientId, headers)?.resourceSubscriptions + if (subscriptions === undefined) { + return Effect.fail( + new McpProtocol.ProtocolError({ + code: PublicMcpSchema.METHOD_NOT_FOUND_ERROR_CODE, + message: "Resource subscriptions are not supported" + }) + ) + } + return Effect.sync(() => subscriptions.delete(uri)).pipe(Effect.asVoid) + }, + effectLogLevel, + canDeliver: (clientId, headers, notification, fallbackLogLevel) => { + const session = resolveSession(clientId, headers) + if (notification._tag === "LoggingMessage") { + const minimum = session?.logLevel + return minimum?._tag === "Mcp" + ? mcpLogLevels[notification.level].order >= mcpLogLevels[minimum.level].order + : LogLevel.isGreaterThanOrEqualTo( + mcpLogLevels[notification.level].effect, + minimum?.level ?? fallbackLogLevel + ) + } + return notification._tag !== "ResourceUpdated" || session?.resourceSubscriptions?.has(notification.uri) === true + }, + markInitialized: (clientId) => { + initializedClientIds.add(clientId) + }, + initializedClientIds: () => initializedClientIds.values(), + disconnect: (clientId) => { + byClientId.delete(clientId) + initializedClientIds.delete(clientId) + } + } +} diff --git a/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts b/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts index 4db2240b520..0d0bf9f6a4c 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import * as McpProtocol from "effect/unstable/ai/internal/mcpProtocol" import * as McpProtocolRegistry from "effect/unstable/ai/internal/mcpProtocolRegistry" +import * as McpRuntime from "effect/unstable/ai/internal/mcpRuntime" import * as McpSchema2025_06_18 from "effect/unstable/ai/internal/mcpSchema/v2025_06_18" import type * as PublicMcpProtocol from "effect/unstable/ai/McpProtocol" import * as McpSchema from "effect/unstable/ai/McpSchema" @@ -29,10 +30,10 @@ const makeTestProtocol = < return McpProtocol.make({ protocolVersion, - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: true - }, + runtime: McpRuntime.stateful({ + jsonRpc: { acceptsBatches: false }, + http: { requiresVersionHeader: true } + }), clientRpcs: RpcGroup.make(TestRequest), clientNotificationRpcs: RpcGroup.make(), serverRequestRpcs: RpcGroup.make(), diff --git a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 635f1da7772..03800e8a63d 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -223,6 +223,32 @@ describe("McpServer", () => { assert.isUndefined(received) })) + + it.effect("should provide neutral and legacy request services to legacy handlers", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + let received: string | undefined + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "request-services", + inputSchema: { type: "object" } + }), + annotations: Context.empty(), + handle: () => + Effect.gen(function*() { + const request = yield* McpSchema.McpRequestContext + const client = yield* McpSchema.McpServerClient + received = `${request.protocolVersion}:${client.initializePayload.clientInfo.name}` + return new McpSchema.CallToolResult({ content: [] }) + }) + }) + + yield* server.callTool({ name: "request-services" }).pipe( + Effect.provideService(McpSchema.McpServerClient, directClient) + ) + + assert.strictEqual(received, "2025-06-18:TestClient") + })) }) it.effect("should reject browser Origins by default while accepting Origin-less clients", () => @@ -558,7 +584,7 @@ describe("McpServer", () => { }) describe("resource subscriptions", () => { - it.effect("should isolate resource update subscriptions between sessions", () => + it.effect("should isolate resource subscriptions and clear disconnected sessions", () => Effect.gen(function*() { const clientIds = new Set([1, 2]) const client1Outbound = yield* Queue.unbounded< @@ -662,6 +688,32 @@ describe("McpServer", () => { assert.strictEqual((yield* nextResourceUpdate(2)).uri, "file:///sentinel") assert.isTrue(Option.isNone(yield* Queue.poll(client1Outbound))) assert.isTrue(Option.isNone(yield* Queue.poll(client2Outbound))) + + clientIds.delete(1) + yield* server.notifications["notifications/resources/updated"]({ uri: "file:///sentinel" }) + assert.strictEqual((yield* nextResourceUpdate(2)).uri, "file:///sentinel") + clientIds.add(1) + yield* send(1, { + _tag: "Request", + id: 30, + tag: "ping", + payload: {}, + headers: [] + }) + const afterSweep = yield* nextResponse(1, 30) + assert.strictEqual(afterSweep.exit._tag, "Failure") + + yield* initialize(1) + yield* send(1, { _tag: "Eof" }) + yield* send(1, { + _tag: "Request", + id: 31, + tag: "ping", + payload: {}, + headers: [] + }) + const afterReconnect = yield* nextResponse(1, 31) + assert.strictEqual(afterReconnect.exit._tag, "Failure") })) }) diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts index d6d872966a9..1d381729913 100644 --- a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -46,7 +46,7 @@ const ValidatedTool = Tool.make("validated", { const CapabilityTool = Tool.make("capability", { parameters: Tool.EmptyParams, success: Schema.String, - dependencies: [McpSchema.McpServerClient] + dependencies: [McpSchema.McpRequestContext] }) const InitializeMetadataTool = Tool.make("initialize-metadata", { @@ -67,7 +67,7 @@ const CapabilityGatedTool = Tool.make("capability-gated", { }).annotate( McpSchema.EnabledWhen, (client) => - client.clientInfo.name === "allowed-client" && + client.clientInfo?.name === "allowed-client" && client.capabilities.roots !== undefined ) From cbd831d72652df43d3871004e847e35d0710082d Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Fri, 14 Aug 2026 07:57:32 +0200 Subject: [PATCH 2/9] feat: add MCP 2026-07-28 protocol adapter --- .changeset/modern-mice-discover.md | 5 + .../effect/src/unstable/ai/McpProtocol.ts | 43 +- packages/effect/src/unstable/ai/McpSchema.ts | 4 +- packages/effect/src/unstable/ai/McpServer.ts | 2 +- .../src/unstable/ai/internal/mcpCore.ts | 8 +- .../src/unstable/ai/internal/mcpProtocol.ts | 13 +- .../ai/internal/mcpProtocol/v2026_07_28.ts | 392 ++++++++++++ .../src/unstable/ai/internal/mcpRuntime.ts | 14 +- .../ai/internal/mcpSchema/v2026_07_28.ts | 588 ++++++++++++++++++ .../ai/McpServer/ProtocolAdapters.test.ts | 241 ++++++- .../typetest/unstable/ai/McpServer.tst.ts | 15 +- 11 files changed, 1299 insertions(+), 26 deletions(-) create mode 100644 .changeset/modern-mice-discover.md create mode 100644 packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts diff --git a/.changeset/modern-mice-discover.md b/.changeset/modern-mice-discover.md new file mode 100644 index 00000000000..8cbf5644556 --- /dev/null +++ b/.changeset/modern-mice-discover.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add server support for MCP protocol version 2026-07-28 through `McpProtocol.v2026_07_28`. Modern requests use stateless, per-request negotiation without creating or consulting legacy sessions. diff --git a/packages/effect/src/unstable/ai/McpProtocol.ts b/packages/effect/src/unstable/ai/McpProtocol.ts index ceef8f7a6c4..992dafb6df1 100644 --- a/packages/effect/src/unstable/ai/McpProtocol.ts +++ b/packages/effect/src/unstable/ai/McpProtocol.ts @@ -13,6 +13,7 @@ import { protocol as protocol2024_11_05 } from "./internal/mcpProtocol/v2024_11_ import { protocol as protocol2025_03_26 } from "./internal/mcpProtocol/v2025_03_26.ts" import { protocol as protocol2025_06_18 } from "./internal/mcpProtocol/v2025_06_18.ts" import { protocol as protocol2025_11_25 } from "./internal/mcpProtocol/v2025_11_25.ts" +import { protocol as protocol2026_07_28 } from "./internal/mcpProtocol/v2026_07_28.ts" import type * as McpSchema from "./McpSchema.ts" /** @@ -21,7 +22,15 @@ import type * as McpSchema from "./McpSchema.ts" * @category models * @since 4.0.0 */ -export type ProtocolVersion = "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" +export type ProtocolVersion = "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" | "2026-07-28" + +/** + * MCP protocol versions that use initialization and server-managed sessions. + * + * @category models + * @since 4.0.0 + */ +export type StatefulProtocolVersion = Exclude /** * Payload codecs used by a protocol adapter. @@ -164,9 +173,27 @@ export interface AnyProtocolAdapter< * @category models * @since 4.0.0 */ -export interface ProtocolAdapter - extends AnyProtocolAdapter -{} +export interface ProtocolAdapter< + out Version extends ProtocolVersion = ProtocolVersion, + out Runtime extends RuntimeDescriptor = RuntimeDescriptor +> extends AnyProtocolAdapter {} + +/** + * The MCP 2026-07-28 protocol implementation. + * + * **Details** + * + * This revision uses request-scoped metadata instead of initialization and + * protocol-level sessions. + * + * **Gotchas** + * + * Change-notification subscriptions are not currently advertised or served. + * + * @category protocols + * @since 4.0.0 + */ +export const v2026_07_28: ProtocolAdapter<"2026-07-28", StatelessRuntimeDescriptor> = protocol2026_07_28 /** * The MCP 2025-11-25 protocol implementation. @@ -174,7 +201,7 @@ export interface ProtocolAdapter = protocol2025_11_25 +export const v2025_11_25: ProtocolAdapter<"2025-11-25", StatefulRuntimeDescriptor> = protocol2025_11_25 /** * The MCP 2025-06-18 protocol implementation. @@ -182,7 +209,7 @@ export const v2025_11_25: ProtocolAdapter<"2025-11-25"> = protocol2025_11_25 * @category protocols * @since 4.0.0 */ -export const v2025_06_18: ProtocolAdapter<"2025-06-18"> = protocol2025_06_18 +export const v2025_06_18: ProtocolAdapter<"2025-06-18", StatefulRuntimeDescriptor> = protocol2025_06_18 /** * The MCP 2025-03-26 protocol implementation. @@ -190,7 +217,7 @@ export const v2025_06_18: ProtocolAdapter<"2025-06-18"> = protocol2025_06_18 * @category protocols * @since 4.0.0 */ -export const v2025_03_26: ProtocolAdapter<"2025-03-26"> = protocol2025_03_26 +export const v2025_03_26: ProtocolAdapter<"2025-03-26", StatefulRuntimeDescriptor> = protocol2025_03_26 /** * The MCP 2024-11-05 protocol implementation. @@ -205,4 +232,4 @@ export const v2025_03_26: ProtocolAdapter<"2025-03-26"> = protocol2025_03_26 * @category protocols * @since 4.0.0 */ -export const v2024_11_05: ProtocolAdapter<"2024-11-05"> = protocol2024_11_05 +export const v2024_11_05: ProtocolAdapter<"2024-11-05", StatefulRuntimeDescriptor> = protocol2024_11_05 diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index fbbf7b2dbf2..94bb151797b 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -24,7 +24,7 @@ import type * as Scope from "../../Scope.ts" import * as Rpc from "../rpc/Rpc.ts" import * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcMiddleware from "../rpc/RpcMiddleware.ts" -import type { ProtocolVersion } from "./McpProtocol.ts" +import type { ProtocolVersion, StatefulProtocolVersion } from "./McpProtocol.ts" /** * Schema type returned by `optionalWithDefault`. @@ -2682,7 +2682,7 @@ export class McpRequestContext extends Context.Service /** @internal */ -export interface NegotiatedProtocolProfile< - out Version extends string = McpProtocol.ProtocolVersion -> { +export interface NegotiatedProtocolProfile { // Core decisions receive negotiated facts rather than dated wire requests. readonly protocolVersion: Version readonly clientCapabilities: McpSchema.ClientCapabilities - readonly clientInfo: Version extends McpProtocol.ProtocolVersion ? McpSchema.Implementation + readonly clientInfo: Version extends McpProtocol.StatefulProtocolVersion ? McpSchema.Implementation : McpSchema.Implementation | undefined readonly requestMetadata?: - | (Version extends McpProtocol.ProtocolVersion ? CanonicalRequestMetadata + | (Version extends McpProtocol.StatefulProtocolVersion ? CanonicalRequestMetadata : CanonicalRequestMetadata | Schema.JsonObject) | undefined } diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index e8f08e54e38..6174fb797b9 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -297,6 +297,9 @@ export interface ProtocolAdapter< ) => Effect.Effect } +type HandlerLifecycle = Runtime extends + PublicMcpProtocol.StatefulRuntimeDescriptor ? LifecycleRuntime : undefined + /** @internal */ export const make = < const Version extends string, @@ -318,7 +321,7 @@ export const make = < readonly makeHandlers?: | (( core: McpCore.McpCore, - lifecycle: LifecycleRuntime, + lifecycle: HandlerLifecycle, context: HandlerInstallationContext ) => Handlers) | undefined @@ -368,7 +371,13 @@ export const make = < : target.install( options, options.handlerRpcs, - options.makeHandlers(core, lifecycle!, target.context) + options.makeHandlers( + core, + // TypeScript cannot narrow HandlerLifecycle from the generic runtime tag; the preceding guard + // enforces the stateful lifecycle invariant. + (options.runtime._tag === "Stateful" ? lifecycle : undefined) as HandlerLifecycle, + target.context + ) ) const makeReverseClient = ( diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts new file mode 100644 index 00000000000..17ea1992df8 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts @@ -0,0 +1,392 @@ +/** + * MCP v2026-07-28 wire projections. + * + * @internal + */ +import * as Effect from "../../../../Effect.ts" +import * as Encoding from "../../../../Encoding.ts" +import * as Match from "../../../../Match.ts" +import * as Schema from "../../../../Schema.ts" +import * as PublicMcpSchema from "../../McpSchema.ts" +import * as McpCore from "../mcpCore.ts" +import * as McpProtocol from "../mcpProtocol.ts" +import * as McpSchema from "../mcpSchema/v2026_07_28.ts" + +const JsonObject = Schema.Record(Schema.String, Schema.Json) +const decodeRequestMetadata = Schema.decodeUnknownEffect(McpSchema.RequestMetaObject) + +const omitUndefined = (value: Readonly>): Record => { + const result: Record = {} + for (const key in value) { + if (value[key] !== undefined) { + result[key] = value[key] + } + } + return result +} + +/** @internal */ +export interface StatelessRequestProfile { + readonly protocolVersion: typeof McpSchema.protocolVersion + readonly clientCapabilities: typeof McpSchema.ClientCapabilities.Type + readonly clientInfo?: typeof McpSchema.Implementation.Type | undefined + readonly requestMetadata: typeof McpSchema.RequestMetaObject.Type +} + +export const profileFromRequestMetadata = Effect.fnUntraced(function*(metadata: unknown) { + const requestMetadata = yield* decodeRequestMetadata(metadata) + return { + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: requestMetadata["io.modelcontextprotocol/clientCapabilities"], + clientInfo: requestMetadata["io.modelcontextprotocol/clientInfo"], + requestMetadata + } satisfies StatelessRequestProfile +}) + +const resultMetadata = ( + value: Readonly>, + serverInfo: Schema.JsonObject +): Schema.JsonObject => { + const metadata = value._meta + return { + ...(Schema.is(JsonObject)(metadata) ? metadata : {}), + "io.modelcontextprotocol/serverInfo": serverInfo + } +} + +const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { + return Match.value(content).pipe( + Match.when({ type: Match.is("text", "resource_link") }, (content) => content), + Match.when({ type: Match.is("image", "audio") }, (content) => + omitUndefined({ + type: content.type, + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations, + _meta: content._meta + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return omitUndefined({ + type: "resource", + resource: omitUndefined({ + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + text: resource.text + }), + annotations: content.annotations, + _meta: content._meta + }) + } + return omitUndefined({ + type: "resource", + resource: omitUndefined({ + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + blob: Encoding.encodeBase64(resource.blob) + }), + annotations: content.annotations, + _meta: content._meta + }) + }), + Match.exhaustive + ) +}) + +const projectResource = (resource: PublicMcpSchema.Resource) => McpSchema.Resource.make(resource) +const projectResourceTemplate = (resourceTemplate: PublicMcpSchema.ResourceTemplate) => + McpSchema.ResourceTemplate.make(resourceTemplate) + +const projectPrompt = (prompt: PublicMcpSchema.Prompt) => + McpProtocol.transcode(PublicMcpSchema.Prompt, McpSchema.Prompt, prompt) + +const projectTool = (tool: PublicMcpSchema.Tool) => McpProtocol.transcode(PublicMcpSchema.Tool, McpSchema.Tool, tool) + +const privateStaleCache = { + ttlMs: 0, + cacheScope: "private" +} satisfies { readonly ttlMs: number; readonly cacheScope: "private" } + +const projectCompleteResult = Effect.fnUntraced(function*( + value: Readonly>, + serverInfo: typeof McpSchema.Implementation.Type +) { + const encodedServerInfo = yield* Schema.encodeEffect(McpSchema.Implementation)(serverInfo) + return { + ...omitUndefined(value), + _meta: resultMetadata(value, encodedServerInfo), + resultType: "complete" + } +}) + +/** @internal */ +export const projectError = (error: unknown): typeof McpSchema.McpError.Type => { + let protocolError: McpProtocol.ProtocolError + if (error instanceof McpCore.ResourceNotFound) { + protocolError = new McpProtocol.ProtocolError({ + code: McpSchema.INVALID_PARAMS, + message: `Resource '${error.uri}' not found` + }) + } else if (error instanceof McpCore.ToolResultProjectionError) { + protocolError = new McpProtocol.ProtocolError({ + code: McpSchema.INTERNAL_ERROR, + message: error.message + }) + } else if ( + error instanceof McpCore.ToolNotFound || + error instanceof McpCore.InvalidToolInput || + error instanceof McpCore.ToolExecutionError || + error instanceof McpCore.UnsupportedByProtocol + ) { + protocolError = McpProtocol.ProtocolError.fromTool(error) + } else { + protocolError = McpProtocol.ProtocolError.fromFeature(error) + } + return McpSchema.McpError.make({ + code: protocolError.code, + message: protocolError.message, + ...(Schema.is(Schema.Json)(protocolError.data) ? { data: protocolError.data } : {}) + }) +} + +export const normalizeCancellation = (payload: unknown) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"] +): PublicMcpSchema.McpReverseOperationUnsupported => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason: "MCP 2026-07-28 carries server input requests in multi round-trip results" + }) + +/** @internal */ +export interface ServerDiscoveryContext { + readonly supportedVersions: ReadonlyArray + readonly capabilities: typeof McpSchema.ServerCapabilities.Type + readonly serverInfo: typeof McpSchema.Implementation.Type +} + +/** @internal */ +export const handlerRpcs = McpSchema.ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +export const makeHandlers = ( + core: McpCore.McpCore, + _lifecycle: McpProtocol.LifecycleRuntime | undefined, + context: McpProtocol.HandlerInstallationContext +) => { + const discovery: ServerDiscoveryContext = { + supportedVersions: context.supportedVersions, + capabilities: { + completions: {}, + logging: {}, + ...(context.registrationPresence.tools ? { tools: { listChanged: false } } : {}), + ...(context.registrationPresence.resources ? { resources: { listChanged: false, subscribe: false } } : {}), + ...(context.registrationPresence.prompts ? { prompts: { listChanged: false } } : {}) + }, + serverInfo: context.serverInfo + } + const getInvocation = PublicMcpSchema.McpRequestContext.useSync( + McpProtocol.invocationFromRequestContext + ) + return ({ + "server/discover": Effect.fnUntraced(function*( + _request: typeof McpSchema.Discover.payloadSchema.Type + ) { + const result = yield* projectCompleteResult({ + ...privateStaleCache, + supportedVersions: Array.from(discovery.supportedVersions), + capabilities: discovery.capabilities + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.DiscoverResult)(result) + }, Effect.mapError(projectError)), + "resources/list": Effect.fnUntraced(function*( + _request: typeof McpSchema.ListResources.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const resources = yield* core.resources.list(invocation.protocol) + const result = yield* projectCompleteResult({ + ...privateStaleCache, + resources: resources.map(projectResource) + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.ListResourcesResult)(result) + }, Effect.mapError(projectError)), + "resources/templates/list": Effect.fnUntraced(function*( + _request: typeof McpSchema.ListResourceTemplates.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const resourceTemplates = yield* core.resources.listTemplates(invocation.protocol) + const result = yield* projectCompleteResult({ + ...privateStaleCache, + resourceTemplates: resourceTemplates.map(projectResourceTemplate) + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.ListResourceTemplatesResult)(result) + }, Effect.mapError(projectError)), + "resources/read": Effect.fnUntraced(function*( + { uri }: typeof McpSchema.ReadResource.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const read = yield* core.resources.read(uri, invocation) + const contents = read.contents.map((content) => + "text" in content + ? omitUndefined({ + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + text: content.text + }) + : omitUndefined({ + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + blob: Encoding.encodeBase64(content.blob) + }) + ) + const result = yield* projectCompleteResult({ + ...privateStaleCache, + contents, + _meta: read._meta + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.ReadResourceResult)(result) + }, Effect.mapError(projectError)), + "prompts/list": Effect.fnUntraced(function*( + _request: typeof McpSchema.ListPrompts.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const prompts = yield* core.prompts.list(invocation.protocol) + const projectedPrompts = yield* Effect.forEach(prompts, projectPrompt) + const result = yield* projectCompleteResult({ + ...privateStaleCache, + prompts: projectedPrompts + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.ListPromptsResult)(result) + }, Effect.mapError(projectError)), + "prompts/get": Effect.fnUntraced(function*( + { arguments: args, name }: typeof McpSchema.GetPrompt.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const prompt = yield* core.prompts.get(name, args ?? {}, invocation) + const messages = yield* Effect.forEach(prompt.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })) + )) + const result = yield* projectCompleteResult({ + description: prompt.description, + messages, + _meta: prompt._meta + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.GetPromptResult)(result) + }, Effect.mapError(projectError)), + "completion/complete": Effect.fnUntraced(function*( + request: typeof McpSchema.Complete.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const completion = yield* core.completions.complete({ + reference: request.ref.type === "ref/prompt" + ? { type: "prompt", name: request.ref.name, title: request.ref.title } + : { type: "resourceTemplate", uriTemplate: request.ref.uri }, + argument: request.argument, + context: request.context?.arguments === undefined + ? undefined + : { arguments: request.context.arguments }, + metadata: request._meta + }, invocation) + const result = yield* projectCompleteResult({ + completion: omitUndefined({ + values: Array.from(completion.values), + total: completion.total, + hasMore: completion.hasMore + }), + _meta: completion.metadata + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.CompleteResult)(result) + }, Effect.mapError(projectError)), + "tools/list": Effect.fnUntraced(function*( + _request: typeof McpSchema.ListTools.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const tools = yield* core.tools.list(invocation.protocol) + const projectedTools = yield* Effect.forEach(tools, projectTool) + const result = yield* projectCompleteResult({ + ...privateStaleCache, + tools: projectedTools + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.ListToolsResult)(result) + }, Effect.mapError(projectError)), + "tools/call": Effect.fnUntraced(function*( + { arguments: args, name }: typeof McpSchema.CallTool.payloadSchema.Type + ) { + const invocation = yield* getInvocation + const toolResult = yield* core.tools.call({ name, arguments: args ?? {} }, invocation).pipe( + Effect.catchTags({ + InvalidToolInput: (error) => + Effect.succeed(PublicMcpSchema.CallToolResult.make({ + content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], + isError: true + })), + ToolExecutionError: (error) => + Effect.succeed(PublicMcpSchema.CallToolResult.make({ + content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], + isError: true + })) + }) + ) + const content = yield* Effect.forEach(toolResult.content, projectContent) + const result = yield* projectCompleteResult({ + content, + structuredContent: toolResult.structuredContent, + isError: toolResult.isError, + _meta: toolResult._meta + }, discovery.serverInfo) + return yield* Schema.decodeUnknownEffect(McpSchema.CallToolResult)(result) + }, Effect.mapError(projectError)), + "notifications/cancelled": Effect.fnUntraced(function*( + _request: typeof McpSchema.CancelledNotification.payloadSchema.Type + ) { + return yield* Effect.void + }) + }) +} + +const runtime = { + _tag: "Stateless", + transport: { + jsonRpc: { acceptsBatches: false }, + http: {} + }, + profileFromRequestMetadata +} as const + +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + runtime, + clientRpcs: McpSchema.ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs, + makeHandlers, + toReverseClient: () => ({ + listRoots: () => Effect.fail(unsupported("roots/list")), + createMessage: () => Effect.fail(unsupported("sampling/createMessage")), + elicit: () => Effect.fail(unsupported("elicitation/create")) + }), + // TODO: Route change notifications through subscriptions/listen before + // advertising them. The runtime must filter each subscription and add its + // subscription ID to notification _meta before transport delivery. + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation +}) diff --git a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts index 6f70aa4075b..ba4a759174b 100644 --- a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts +++ b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts @@ -20,7 +20,7 @@ import type * as RpcMessage from "../../rpc/RpcMessage.ts" import type * as PublicMcpProtocol from "../McpProtocol.ts" import * as PublicMcpSchema from "../McpSchema.ts" import type * as McpCore from "./mcpCore.ts" -import type * as McpProtocol from "./mcpProtocol.ts" +import * as McpProtocol from "./mcpProtocol.ts" import * as McpProtocolRegistry from "./mcpProtocolRegistry.ts" import * as McpStatefulRuntime from "./mcpStatefulRuntime.ts" @@ -250,6 +250,18 @@ export const make = Effect.fnUntraced(function*( const metadata = typeof request.payload === "object" && request.payload !== null && "_meta" in request.payload ? request.payload._meta : undefined + const requestedVersion = typeof metadata === "object" && metadata !== null && + "io.modelcontextprotocol/protocolVersion" in metadata && + typeof metadata["io.modelcontextprotocol/protocolVersion"] === "string" + ? metadata["io.modelcontextprotocol/protocolVersion"] + : undefined + if (requestedVersion !== undefined && requestedVersion !== protocol.protocolVersion) { + return yield* new McpProtocol.ProtocolError({ + code: -32022, + message: `Unsupported protocol version '${requestedVersion}'`, + data: { supported: protocolVersions, requested: requestedVersion } + }) + } const decodedProfile = yield* statelessDescriptor.profileFromRequestMetadata(metadata) const profile: McpCore.NegotiatedProtocolProfile = { protocolVersion: decodedProfile.protocolVersion, diff --git a/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts new file mode 100644 index 00000000000..8b5fa5cd790 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts @@ -0,0 +1,588 @@ +/** + * MCP v2026-07-28 wire schemas. + * + * @internal + */ +import * as Schema from "../../../../Schema.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" +import * as Previous from "./v2025_11_25.ts" + +export * from "./v2025_11_25.ts" + +export const protocolVersion = "2026-07-28" + +const optional = Previous.optional +const JsonObject = Schema.Record(Schema.String, Schema.Json) +const MetaObject = JsonObject +const Meta = optional(MetaObject) + +export const RequestId = Schema.Union([Schema.String, Schema.Int]) +export const ProgressToken = RequestId +export const Role = Previous.Role +export const LoggingLevel = Previous.LoggingLevel +export const Icon = Previous.Icon +export const Implementation = Previous.Implementation + +export const ClientCapabilities = Schema.StructWithRest( + Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + roots: optional(Schema.Struct({})), + sampling: optional(Schema.Struct({ + context: optional(JsonObject), + tools: optional(JsonObject) + })), + elicitation: optional(Schema.Struct({ + form: optional(JsonObject), + url: optional(JsonObject) + })), + extensions: optional(Schema.Record(Schema.String, JsonObject)) + }), + [Schema.Record(Schema.String, Schema.Json)] +) + +export const ServerCapabilities = Schema.StructWithRest( + Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + logging: optional(JsonObject), + completions: optional(JsonObject), + prompts: optional(Schema.Struct({ listChanged: optional(Schema.Boolean) })), + resources: optional(Schema.Struct({ + subscribe: optional(Schema.Boolean), + listChanged: optional(Schema.Boolean) + })), + tools: optional(Schema.Struct({ listChanged: optional(Schema.Boolean) })), + extensions: optional(Schema.Record(Schema.String, JsonObject)) + }), + [Schema.Record(Schema.String, Schema.Json)] +) + +export const RequestMetaObject = Schema.StructWithRest( + Schema.Struct({ + progressToken: optional(ProgressToken), + "io.modelcontextprotocol/protocolVersion": Schema.String, + "io.modelcontextprotocol/clientInfo": optional(Implementation), + "io.modelcontextprotocol/clientCapabilities": ClientCapabilities, + "io.modelcontextprotocol/logLevel": optional(LoggingLevel) + }), + [Schema.Record(Schema.String, Schema.Json)] +) + +export const RequestParams = Schema.Struct({ _meta: RequestMetaObject }) + +export const NotificationMetaObject = Schema.StructWithRest( + Schema.Struct({ + "io.modelcontextprotocol/subscriptionId": optional(RequestId) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export const NotificationParams = Schema.Struct({ _meta: optional(NotificationMetaObject) }) + +export const ResultMetaObject = Schema.StructWithRest( + Schema.Struct({ + "io.modelcontextprotocol/serverInfo": Implementation + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export const ResultMeta = { + _meta: ResultMetaObject, + resultType: Schema.Literal("complete") +} +export const Result = Schema.StructWithRest( + Schema.Struct(ResultMeta), + [Schema.Record(Schema.String, Schema.Json)] +) +export const EmptyResult = Result + +export const McpError = Schema.Struct({ + code: Schema.Int, + message: Schema.String, + data: optional(Schema.Json) +}) +export type McpError = typeof McpError.Type + +export const PARSE_ERROR = -32700 +export const INVALID_REQUEST = -32600 +export const METHOD_NOT_FOUND = -32601 +export const INVALID_PARAMS = -32602 +export const INTERNAL_ERROR = -32603 +export const HEADER_MISMATCH = -32020 +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 +export const UNSUPPORTED_PROTOCOL_VERSION = -32022 + +const error = (code: number) => + Schema.Struct({ + code: Schema.Literal(code), + message: Schema.String, + data: optional(Schema.Json) + }) + +export const ParseError = error(PARSE_ERROR) +export const InvalidRequestError = error(INVALID_REQUEST) +export const MethodNotFoundError = error(METHOD_NOT_FOUND) +export const InvalidParamsError = error(INVALID_PARAMS) +export const InternalError = error(INTERNAL_ERROR) +export const HeaderMismatchError = error(HEADER_MISMATCH) +export const UnsupportedProtocolVersionError = Schema.Struct({ + code: Schema.Literal(UNSUPPORTED_PROTOCOL_VERSION), + message: Schema.String, + data: Schema.Struct({ + supported: Schema.Array(Schema.String), + requested: Schema.String + }) +}) +export const MissingRequiredClientCapabilityError = Schema.Struct({ + code: Schema.Literal(MISSING_REQUIRED_CLIENT_CAPABILITY), + message: Schema.String, + data: Schema.Struct({ requiredCapabilities: ClientCapabilities }) +}) + +export const Annotations = Previous.Annotations + +export const Resource = Schema.Struct({ + ...Previous.Resource.fields, + size: optional(Schema.Int), + annotations: optional(Annotations) +}) + +export const ResourceTemplate = Previous.ResourceTemplate +export const TextResourceContents = Previous.TextResourceContents +export const BlobResourceContents = Previous.BlobResourceContents +export const ResourceContents = Previous.ResourceContents +export const TextContent = Previous.TextContent +export const ImageContent = Previous.ImageContent +export const AudioContent = Previous.AudioContent +export const ResourceLink = Schema.Struct({ ...Resource.fields, type: Schema.Literal("resource_link") }) +export const EmbeddedResource = Previous.EmbeddedResource +export const ContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + ResourceLink, + EmbeddedResource +]) + +export const PromptArgument = Previous.PromptArgument +export const Prompt = Previous.Prompt +export const PromptMessage = Schema.Struct({ role: Role, content: ContentBlock }) + +const ToolInputSchema = Schema.StructWithRest( + Schema.Struct({ + $schema: optional(Schema.String), + type: Schema.Literal("object") + }), + [Schema.Record(Schema.String, Schema.Json)] +) +const ToolOutputSchema = Schema.StructWithRest( + Schema.Struct({ $schema: optional(Schema.String) }), + [Schema.Record(Schema.String, Schema.Json)] +) +export const ToolAnnotations = Previous.ToolAnnotations +export const Tool = Schema.Struct({ + ...Previous.Tool.fields, + inputSchema: ToolInputSchema, + outputSchema: optional(ToolOutputSchema), + annotations: optional(ToolAnnotations) +}) + +export const ToolUseContent = Previous.ToolUseContent +export const ToolResultContent = Schema.Struct({ + type: Schema.Literal("tool_result"), + toolUseId: Schema.String, + content: Schema.Array(ContentBlock), + structuredContent: optional(Schema.Json), + isError: optional(Schema.Boolean), + _meta: Meta +}) +export const SamplingMessageContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + ToolUseContent, + ToolResultContent +]) +export const SamplingMessage = Schema.Struct({ + role: Role, + content: Schema.Union([SamplingMessageContentBlock, Schema.Array(SamplingMessageContentBlock)]), + _meta: Meta +}) +export const ModelHint = Schema.StructWithRest( + Schema.Struct({ name: optional(Schema.String) }), + [Schema.Record(Schema.String, Schema.Json)] +) +const ModelPriority = Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 })) +export const ModelPreferences = Schema.Struct({ + ...Previous.ModelPreferences.fields, + hints: optional(Schema.Array(ModelHint)), + costPriority: optional(ModelPriority), + speedPriority: optional(ModelPriority), + intelligencePriority: optional(ModelPriority) +}) +export const ToolChoice = Previous.ToolChoice +export const CreateMessageRequest = Schema.Struct({ + method: Schema.Literal("sampling/createMessage"), + params: Schema.Struct({ + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Int, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(JsonObject), + tools: optional(Schema.Array(Tool)), + toolChoice: optional(ToolChoice) + }) +}) +export const CreateMessageResult = Schema.Struct({ + ...SamplingMessage.fields, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export const Root = Previous.Root +export const ListRootsRequest = Schema.Struct({ + method: Schema.Literal("roots/list"), + params: optional(Schema.Struct({ _meta: Meta })) +}) +export const ListRootsResult = Schema.Struct({ roots: Schema.Array(Root) }) + +export const StringSchema = Previous.StringSchema +export const NumberSchema = Previous.NumberSchema +export const BooleanSchema = Previous.BooleanSchema +export const UntitledSingleSelectEnumSchema = Previous.UntitledSingleSelectEnumSchema +export const TitledSingleSelectEnumSchema = Previous.TitledSingleSelectEnumSchema +export const SingleSelectEnumSchema = Previous.SingleSelectEnumSchema +export const UntitledMultiSelectEnumSchema = Previous.UntitledMultiSelectEnumSchema +export const TitledMultiSelectEnumSchema = Previous.TitledMultiSelectEnumSchema +export const MultiSelectEnumSchema = Previous.MultiSelectEnumSchema +export const LegacyTitledEnumSchema = Previous.LegacyTitledEnumSchema +export const EnumSchema = Schema.Union([ + LegacyTitledEnumSchema, + SingleSelectEnumSchema, + MultiSelectEnumSchema +]) +export const PrimitiveSchemaDefinition = Schema.Union([ + StringSchema, + NumberSchema, + BooleanSchema, + EnumSchema +]) +export const RequestedSchema = Schema.Struct({ + $schema: optional(Schema.String), + type: Schema.Literal("object"), + properties: Schema.Record(Schema.String, PrimitiveSchemaDefinition), + required: optional(Schema.Array(Schema.String)) +}) +export const ElicitRequestFormParams = Schema.Struct({ + mode: optional(Schema.Literal("form")), + message: Schema.String, + requestedSchema: RequestedSchema +}) +export const ElicitRequestURLParams = Schema.Struct({ + mode: Schema.Literal("url"), + message: Schema.String, + url: Schema.String +}) +export const ElicitRequestParams = Schema.Union([ElicitRequestFormParams, ElicitRequestURLParams]) +export const ElicitRequest = Schema.Struct({ + method: Schema.Literal("elicitation/create"), + params: ElicitRequestParams +}) +export const ElicitResult = Schema.Struct({ + action: Schema.Literals(["accept", "decline", "cancel"]), + content: optional(Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Finite, Schema.Boolean, Schema.Array(Schema.String)]) + )) +}) + +export const InputRequest = Schema.Union([CreateMessageRequest, ListRootsRequest, ElicitRequest]) +export const InputResponse = Schema.Union([CreateMessageResult, ListRootsResult, ElicitResult]) +export const InputRequests = Schema.Record(Schema.String, InputRequest) +export const InputResponses = Schema.Record(Schema.String, InputResponse) +const InputRequiredResultMeta = { + _meta: ResultMetaObject, + resultType: Schema.Literal("input_required") +} +export const InputRequiredResult = Schema.Union([ + Schema.StructWithRest( + Schema.Struct({ + ...InputRequiredResultMeta, + inputRequests: InputRequests, + requestState: optional(Schema.String) + }), + [Schema.Record(Schema.String, Schema.Json)] + ), + Schema.StructWithRest( + Schema.Struct({ + ...InputRequiredResultMeta, + inputRequests: optional(InputRequests), + requestState: Schema.String + }), + [Schema.Record(Schema.String, Schema.Json)] + ) +]) +export const InputResponseRequestParams = { + ...RequestParams.fields, + inputResponses: optional(InputResponses), + requestState: optional(Schema.String) +} + +export const CacheableResult = { + ...ResultMeta, + ttlMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cacheScope: Schema.Literals(["public", "private"]) +} +export const PaginatedRequestParams = { + ...RequestParams.fields, + cursor: optional(Schema.String) +} +export const PaginatedResult = { + ...ResultMeta, + nextCursor: optional(Schema.String) +} + +export const DiscoverResult = Schema.StructWithRest( + Schema.Struct({ + ...CacheableResult, + supportedVersions: Schema.Array(Schema.String), + capabilities: ServerCapabilities, + instructions: optional(Schema.String) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class Discover extends Rpc.make("server/discover", { + success: DiscoverResult, + error: McpError, + payload: RequestParams +}) {} + +export const ListResourcesResult = Schema.StructWithRest( + Schema.Struct({ + ...PaginatedResult, + ...CacheableResult, + resources: Schema.Array(Resource) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: McpError, + payload: PaginatedRequestParams +}) {} + +export const ListResourceTemplatesResult = Schema.StructWithRest( + Schema.Struct({ + ...PaginatedResult, + ...CacheableResult, + resourceTemplates: Schema.Array(ResourceTemplate) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: McpError, + payload: PaginatedRequestParams +}) {} + +export const ReadResourceResult = Schema.StructWithRest( + Schema.Struct({ + ...CacheableResult, + contents: Schema.Array(ResourceContents) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class ReadResource extends Rpc.make("resources/read", { + success: Schema.Union([ReadResourceResult, InputRequiredResult]), + error: McpError, + payload: { ...InputResponseRequestParams, uri: Schema.String } +}) {} + +export const ListPromptsResult = Schema.StructWithRest( + Schema.Struct({ + ...PaginatedResult, + ...CacheableResult, + prompts: Schema.Array(Prompt) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: McpError, + payload: PaginatedRequestParams +}) {} + +export const GetPromptResult = Schema.StructWithRest( + Schema.Struct({ + ...ResultMeta, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class GetPrompt extends Rpc.make("prompts/get", { + success: Schema.Union([GetPromptResult, InputRequiredResult]), + error: McpError, + payload: { + ...InputResponseRequestParams, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export const ListToolsResult = Schema.StructWithRest( + Schema.Struct({ + ...PaginatedResult, + ...CacheableResult, + tools: Schema.Array(Tool) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: McpError, + payload: PaginatedRequestParams +}) {} + +export const CallToolResult = Schema.StructWithRest( + Schema.Struct({ + ...ResultMeta, + content: Schema.Array(ContentBlock), + structuredContent: optional(Schema.Json), + isError: optional(Schema.Boolean) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class CallTool extends Rpc.make("tools/call", { + success: Schema.Union([CallToolResult, InputRequiredResult]), + error: McpError, + payload: { + ...InputResponseRequestParams, + name: Schema.String, + arguments: optional(JsonObject) + } +}) {} + +export const PromptReference = Previous.PromptReference +export const ResourceTemplateReference = Previous.ResourceTemplateReference +export const CompleteResult = Schema.StructWithRest( + Schema.Struct({ + ...ResultMeta, + completion: Schema.Struct({ + values: Schema.Array(Schema.String).check(Schema.isMaxLength(100)), + total: optional(Schema.Int), + hasMore: optional(Schema.Boolean) + }) + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class Complete extends Rpc.make("completion/complete", { + success: CompleteResult, + error: McpError, + payload: { + ...RequestParams.fields, + ref: Schema.Union([PromptReference, ResourceTemplateReference]), + argument: Schema.Struct({ name: Schema.String, value: Schema.String }), + context: optional(Schema.Struct({ + arguments: optional(Schema.Record(Schema.String, Schema.String)) + })) + } +}) {} + +export const SubscriptionFilter = Schema.Struct({ + toolsListChanged: optional(Schema.Boolean), + promptsListChanged: optional(Schema.Boolean), + resourcesListChanged: optional(Schema.Boolean), + resourceSubscriptions: optional(Schema.Array(Schema.String)) +}) +export const SubscriptionsListenResultMetaObject = Schema.StructWithRest( + Schema.Struct({ + "io.modelcontextprotocol/serverInfo": Implementation, + "io.modelcontextprotocol/subscriptionId": RequestId + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export const SubscriptionsListenResult = Schema.StructWithRest( + Schema.Struct({ + _meta: SubscriptionsListenResultMetaObject, + resultType: Schema.Literal("complete") + }), + [Schema.Record(Schema.String, Schema.Json)] +) +export class SubscriptionsListen extends Rpc.make("subscriptions/listen", { + success: SubscriptionsListenResult, + error: McpError, + payload: { ...RequestParams.fields, notifications: SubscriptionFilter } +}) {} + +export class CancelledNotification extends Rpc.make("notifications/cancelled", { + payload: { + ...NotificationParams.fields, + requestId: RequestId, + reason: optional(Schema.String) + } +}) {} +export class ProgressNotification extends Rpc.make("notifications/progress", { + payload: { + ...NotificationParams.fields, + progressToken: ProgressToken, + progress: Schema.Finite, + total: optional(Schema.Finite), + message: optional(Schema.String) + } +}) {} +export class LoggingMessageNotification extends Rpc.make("notifications/message", { + payload: { + ...NotificationParams.fields, + level: LoggingLevel, + logger: optional(Schema.String), + data: Schema.Json + } +}) {} +export class ResourceUpdatedNotification extends Rpc.make("notifications/resources/updated", { + payload: { ...NotificationParams.fields, uri: Schema.String } +}) {} +export class ResourceListChangedNotification extends Rpc.make("notifications/resources/list_changed", { + payload: Schema.UndefinedOr(NotificationParams) +}) {} +export class ToolListChangedNotification extends Rpc.make("notifications/tools/list_changed", { + payload: Schema.UndefinedOr(NotificationParams) +}) {} +export class PromptListChangedNotification extends Rpc.make("notifications/prompts/list_changed", { + payload: Schema.UndefinedOr(NotificationParams) +}) {} +export class SubscriptionsAcknowledgedNotification extends Rpc.make( + "notifications/subscriptions/acknowledged", + { payload: { ...NotificationParams.fields, notifications: SubscriptionFilter } } +) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Discover, + Complete, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + ReadResource, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make(CancelledNotification) {} +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +// In v2026-07-28 these requests are embedded in InputRequiredResult rather than +// being sent as independent JSON-RPC requests. +export class ServerRequestRpcs extends RpcGroup.make() {} + +export class ServerNotificationRpcs extends RpcGroup.make( + CancelledNotification, + ProgressNotification, + LoggingMessageNotification, + ResourceUpdatedNotification, + ResourceListChangedNotification, + ToolListChangedNotification, + PromptListChangedNotification, + SubscriptionsAcknowledgedNotification +) {} diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts index 1d381729913..0aa589c5fbd 100644 --- a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -182,6 +182,7 @@ const makeFixture = Effect.fnUntraced(function*() { })], path: "/mcp", protocols: [ + McpProtocol.v2026_07_28, McpProtocol.v2025_11_25, McpProtocol.v2025_06_18, McpProtocol.v2025_03_26, @@ -490,6 +491,7 @@ const makeLowLevelFixture = Effect.fnUntraced(function*() { icons: [ServerIcon], path: "/mcp", protocols: [ + McpProtocol.v2026_07_28, McpProtocol.v2025_11_25, McpProtocol.v2025_06_18, McpProtocol.v2025_03_26, @@ -512,7 +514,8 @@ const JsonRpcResponse = Schema.Union([ id: Schema.NullOr(Schema.Number), error: Schema.Struct({ code: Schema.Number, - message: Schema.String + message: Schema.String, + data: Schema.optionalKey(Schema.Unknown) }) }) ]) @@ -520,6 +523,38 @@ type JsonRpcResponse = typeof JsonRpcResponse.Type const decodeJsonRpcResponse = Schema.decodeUnknownEffect(JsonRpcResponse) +const modernMetadata = ( + capabilities: Record = {}, + clientInfo: { readonly name: string; readonly version: string } = { + name: "ModernProtocolAdapterClient", + version: "1.0.0" + } +) => ({ + "io.modelcontextprotocol/protocolVersion": McpProtocol.v2026_07_28.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": capabilities, + "io.modelcontextprotocol/clientInfo": clientInfo +}) + +const modernRequest = ( + id: number, + method: string, + params: Record = {}, + metadata: Record = modernMetadata() +) => ({ + jsonrpc: "2.0", + id, + method, + params: { ...params, _meta: metadata } +}) + +const modernHeaders = ( + method: string, + protocolVersion: string = McpProtocol.v2026_07_28.protocolVersion +): HeadersInit => ({ + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": method +}) + const initialize = Effect.fnUntraced(function*( post: Effect.Success>["post"], protocolVersion: "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05", @@ -606,6 +641,29 @@ const textResult = (message: JsonRpcResponse): string => { } describe("McpServer protocol adapters", () => { + it.effect("should discover the modern server without initialization or a session", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const response = yield* fixture.post( + modernRequest(19, "server/discover"), + modernHeaders("server/discover") + ) + const message = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + const result = resultOf(message) + + assert.strictEqual(response.status, 200) + assert.isNull(response.headers.get("Mcp-Session-Id")) + assert.deepStrictEqual(result.supportedVersions, [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05" + ]) + })) + it.effect("should keep log levels isolated when one session updates its level", () => Effect.gen(function*() { const fixture = yield* makeFixture() @@ -624,6 +682,130 @@ describe("McpServer protocol adapters", () => { ) })) + it.effect("should isolate interleaved stateful and stateless request contexts", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const legacy = yield* initialize(fixture.post, "2025-11-25", { + capabilities: { roots: {} }, + clientInfo: { name: "legacy-client", version: "1.0.0" } + }) + + assert.strictEqual( + textResult(yield* legacy.request("tools/call", { name: "capability" })), + JSON.stringify({ roots: {} }) + ) + + const modernResponse = yield* fixture.post( + modernRequest( + 20, + "tools/call", + { name: "capability", arguments: {} }, + modernMetadata({ sampling: {} }, { + name: "modern-client", + version: "2.0.0" + }) + ), + { ...modernHeaders("tools/call"), "Mcp-Name": "capability", "Mcp-Session-Id": "ignored-modern-session" } + ) + const modern = yield* Effect.promise(() => modernResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + + assert.isNull(modernResponse.headers.get("Mcp-Session-Id")) + assert.strictEqual(textResult(modern), JSON.stringify({ sampling: {} })) + assert.strictEqual( + textResult(yield* legacy.request("tools/call", { name: "capability" })), + JSON.stringify({ roots: {} }) + ) + })) + + it.effect("should keep malformed modern requests out of legacy routing", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const unsupportedVersion = "2099-01-01" + const cases = [ + { + name: "missing request metadata", + body: { jsonrpc: "2.0", id: 30, method: "tools/list", params: {} }, + headers: modernHeaders("tools/list"), + status: 400, + code: -32020 + }, + { + name: "missing protocol header", + body: modernRequest(31, "tools/list"), + headers: { "Mcp-Method": "tools/list" }, + status: 400, + code: -32020 + }, + { + name: "malformed client identity", + body: modernRequest(32, "tools/list", {}, { + ...modernMetadata(), + "io.modelcontextprotocol/clientInfo": true + }), + headers: modernHeaders("tools/list"), + status: 200, + code: McpSchema.INVALID_PARAMS_ERROR_CODE + }, + { + name: "unsupported protocol version", + body: modernRequest(33, "tools/list", {}, { + ...modernMetadata(), + "io.modelcontextprotocol/protocolVersion": unsupportedVersion + }), + headers: modernHeaders("tools/list", unsupportedVersion), + status: 400, + code: -32022, + supported: [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05" + ] + }, + { + name: "modern metadata on initialize", + body: { + jsonrpc: "2.0", + id: 34, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "legacy-shape", version: "1.0.0" }, + _meta: modernMetadata() + } + }, + headers: modernHeaders("initialize"), + status: 400, + code: -32020 + } + ] as const + + for (const testCase of cases) { + const response = yield* fixture.post(testCase.body, testCase.headers) + const message = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + if (!("error" in message)) { + assert.fail(`${testCase.name}: expected error, received result`) + } + const error = message.error + + assert.strictEqual(response.status, testCase.status, testCase.name) + assert.strictEqual(error.code, testCase.code, testCase.name) + if ("supported" in testCase) { + assert.deepStrictEqual( + (error.data as { readonly supported?: ReadonlyArray } | undefined)?.supported, + testCase.supported, + testCase.name + ) + } + } + })) + it.effect("should reject prompt content when the negotiated schema cannot represent it", () => Effect.gen(function*() { const fixture = yield* makeFixture() @@ -821,6 +1003,63 @@ describe("McpServer protocol adapters", () => { assert.strictEqual(sends, 0) }).pipe(Effect.scoped)) + it.effect("should reject direct reverse operations when the selected protocol is stateless", () => + Effect.gen(function*() { + let sends = 0 + const reverseProtocol = yield* RpcClient.Protocol.make(() => + Effect.succeed({ + send: () => + Effect.sync(() => { + sends++ + }), + supportsAck: true, + supportsTransferables: false, + supportsStructuredClone: false + }) + ) + const protocol = McpProtocol.v2026_07_28 + const client = yield* protocol.makeReverseClient({ + protocolVersion: protocol.protocolVersion, + clientCapabilities: {}, + clientInfo: { name: "test", version: "1.0.0" } + }).pipe( + Effect.provideService(RpcClient.Protocol, reverseProtocol) + ) + const operations: ReadonlyArray< + readonly [ + string, + Effect.Effect< + unknown, + McpSchema.McpReverseOperationError | McpSchema.McpReverseOperationUnsupported + > + ] + > = [ + ["roots/list", client.listRoots()], + [ + "sampling/createMessage", + client.createMessage(McpSchema.CreateMessage.payloadSchema.make({ + messages: [{ role: "user", content: { type: "text", text: "sample" } }], + maxTokens: 64 + })) + ], + [ + "elicitation/create", + client.elicit({ + message: "test", + requestedSchema: { type: "object", properties: {} } + }) + ] + ] + + for (const [operation, effect] of operations) { + const error = yield* effect.pipe(Effect.flip) + assert.instanceOf(error, McpSchema.McpReverseOperationUnsupported) + assert.strictEqual(error.operation, operation) + assert.strictEqual(error.protocolVersion, protocol.protocolVersion) + } + assert.strictEqual(sends, 0) + }).pipe(Effect.scoped)) + it.effect("should omit June fields when projecting a March tool descriptor", () => Effect.gen(function*() { const fixture = yield* makeFixture() diff --git a/packages/effect/typetest/unstable/ai/McpServer.tst.ts b/packages/effect/typetest/unstable/ai/McpServer.tst.ts index 14c0c6c12f2..18dc5576934 100644 --- a/packages/effect/typetest/unstable/ai/McpServer.tst.ts +++ b/packages/effect/typetest/unstable/ai/McpServer.tst.ts @@ -71,11 +71,8 @@ describe("McpServer", () => { }) it("should expose every historical protocol adapter", () => { - expect().type.toBe< - "v2024_11_05" | "v2025_03_26" | "v2025_06_18" | "v2025_11_25" - >() expect().type.toBe< - "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" + "2024-11-05" | "2025-03-26" | "2025-06-18" | "2025-11-25" | "2026-07-28" >() }) @@ -84,15 +81,21 @@ describe("McpServer", () => { expect(McpProtocol.v2025_03_26).type.toBeAssignableTo>() expect(McpProtocol.v2025_06_18).type.toBeAssignableTo>() expect(McpProtocol.v2025_11_25).type.toBeAssignableTo>() + expect(McpProtocol.v2026_07_28).type.toBeAssignableTo>() + expect(McpProtocol.v2025_06_18.runtime).type.toBe() + expect(McpProtocol.v2026_07_28.runtime).type.toBe() const protocols: readonly [McpProtocol.ProtocolAdapter, ...Array] = [ McpProtocol.v2024_11_05, McpProtocol.v2025_03_26, McpProtocol.v2025_06_18, - McpProtocol.v2025_11_25 + McpProtocol.v2025_11_25, + McpProtocol.v2026_07_28 ] expect(protocols[0].protocolVersion).type.toBe() + expect(protocols[0].runtime).type.toBe() + expect(protocols[0].runtime.transport).type.toBe() expect(protocols[0].clientRpcs.requests).type.toBeAssignableTo>() }) @@ -112,7 +115,7 @@ describe("McpServer", () => { describe("request context", () => { it("should expose the selected protocol version when a handler reads its client", () => { expect(McpSchema.McpServerClient.useSync((client) => client.protocolVersion)).type.toBe< - Effect.Effect + Effect.Effect >() }) From 7fae9ac1974ae8b41ee4df8ebc06da6f3eea49f0 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Fri, 14 Aug 2026 15:46:45 +0200 Subject: [PATCH 3/9] feat: support MCP multi-round-trip tool results --- .changeset/calm-pandas-listen.md | 5 + packages/effect/src/unstable/ai/McpSchema.ts | 42 ++++++ packages/effect/src/unstable/ai/McpServer.ts | 22 ++- .../src/unstable/ai/internal/mcpCore.ts | 27 +++- .../src/unstable/ai/internal/mcpProtocol.ts | 9 ++ .../ai/internal/mcpProtocol/v2024_11_05.ts | 1 + .../ai/internal/mcpProtocol/v2025_03_26.ts | 1 + .../ai/internal/mcpProtocol/v2025_06_18.ts | 1 + .../ai/internal/mcpProtocol/v2025_11_25.ts | 1 + .../ai/internal/mcpProtocol/v2026_07_28.ts | 105 +++++++++---- .../McpConformance/McpConformanceFixtures.ts | 64 ++++++++ .../McpConformance/MultiRoundTripTest.ts | 139 ++++++++++++++++++ .../ai/McpServer/McpMultiRoundTrip.test.ts | 7 + .../ai/McpServer/ProtocolAdapters.test.ts | 31 ++++ .../typetest/unstable/ai/McpServer.tst.ts | 20 +++ 15 files changed, 437 insertions(+), 38 deletions(-) create mode 100644 .changeset/calm-pandas-listen.md create mode 100644 packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts create mode 100644 packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts diff --git a/.changeset/calm-pandas-listen.md b/.changeset/calm-pandas-listen.md new file mode 100644 index 00000000000..e41609e179a --- /dev/null +++ b/.changeset/calm-pandas-listen.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Allow MCP tool handlers to return `McpSchema.InputRequired` so stateless protocol clients can supply keyed elicitation, sampling, or roots responses through `McpRequestContext` on a later request. diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index 94bb151797b..37e79db0e4c 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -2552,6 +2552,46 @@ export const ElicitResult = Schema.Union([ ElicitDeclineResult ]) +/** + * Version-neutral request for additional MCP client input. + * + * @category models + * @since 4.0.0 + */ +export type McpInputRequest = + | { readonly method: "roots/list"; readonly params?: Schema.JsonObject | undefined } + | { readonly method: "sampling/createMessage"; readonly params: Schema.JsonObject } + | { readonly method: "elicitation/create"; readonly params: Schema.JsonObject } + +/** + * Version-neutral response supplied by an MCP client for a prior input request. + * + * @category models + * @since 4.0.0 + */ +export type McpInputResponse = Schema.JsonObject + +/** + * Indicates that an MCP operation requires additional keyed input from the client. + * + * **When to use** + * + * Use when a handler cannot complete until the client supplies roots, sampling, + * or elicitation input in a later request. + * + * **Details** + * + * `requestState` is opaque to the client and is returned unchanged with the + * keyed input responses. + * + * @category models + * @since 4.0.0 + */ +export class InputRequired extends Data.TaggedClass("InputRequired")<{ + readonly inputRequests: Readonly> + readonly requestState?: string | undefined +}> {} + /** * Sent from the server asking the client to collect structured input from the * user. @@ -2667,6 +2707,8 @@ export class McpRequestContext extends Context.Service | Schema.JsonObject | undefined + readonly inputResponses?: Readonly> | undefined + readonly requestState?: string | undefined }>()("effect/ai/McpSchema/McpRequestContext") {} /** diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 081ffbefbcb..4d7251b075d 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -197,7 +197,11 @@ export class McpServer extends Context.Service readonly handle: ( payload: any - ) => Effect.Effect + ) => Effect.Effect< + CallToolResult | McpSchema.InputRequired, + InternalError | InvalidParams, + McpRequestContext | McpServerClient + > }) => Effect.Effect readonly callTool: ( requests: typeof CallTool.payloadSchema.Type @@ -383,13 +387,16 @@ export class McpServer extends Context.Service - result.structuredContent === undefined + Effect.flatMap((result) => { + if (Predicate.isTagged(result, "InputRequired")) { + return Effect.succeed(McpCore.OperationOutcome.InputRequired(result)) + } + return (result.structuredContent === undefined ? Effect.succeed(result) : validateStructuredContent(options.tool.name, result.structuredContent).pipe( Effect.as(result) - ) - ) + )).pipe(Effect.map(McpCore.OperationOutcome.Complete)) + }) ) }) yield* notifications.client["notifications/tools/list_changed"]({}) @@ -409,7 +416,10 @@ export class McpServer extends Context.Service> + readonly requestState?: string | undefined +} + +/** @internal */ +export type OperationOutcome = Data.TaggedEnum<{ + Complete: { readonly value: A } + InputRequired: InputRequiredFields +}> + +/** @internal */ +export const OperationOutcome = { + Complete: (value: A): OperationOutcome => ({ _tag: "Complete", value }), + InputRequired: (fields: InputRequiredFields): OperationOutcome => ({ + _tag: "InputRequired", + ...fields + }) +} + /** @internal */ export type CanonicalRequestMetadata = NonNullable< typeof McpSchema.Initialize.payloadSchema.Type["_meta"] @@ -119,7 +140,7 @@ export interface ToolRegistration { call: typeof McpSchema.CallTool.payloadSchema.Type, invocation: McpInvocation ) => Effect.Effect< - McpSchema.CallToolResult, + OperationOutcome, InvalidToolInput | ToolExecutionError | ToolResultProjectionError, never > @@ -136,7 +157,7 @@ export interface Tools { readonly call: ( call: typeof McpSchema.CallTool.payloadSchema.Type, invocation: McpInvocation - ) => Effect.Effect + ) => Effect.Effect, ToolError> } /** @internal */ @@ -345,7 +366,7 @@ export const make: Effect.Effect = Effect.sync(() => { return descriptors }), call: (call, invocation) => - Effect.suspend((): Effect.Effect => { + Effect.suspend((): Effect.Effect, ToolError> => { const registration = registrations.get(call.name) if (registration === undefined) { return new ToolNotFound({ name: call.name }) diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index 6174fb797b9..5678dfd20ed 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -53,6 +53,15 @@ export const invocationFromRequestContext = ( requestContext: request }) +/** @internal */ +export const requireCompleteOperation = ( + protocolVersion: PublicMcpProtocol.ProtocolVersion, + outcome: McpCore.OperationOutcome +): Effect.Effect => + outcome._tag === "Complete" + ? Effect.succeed(outcome.value) + : Effect.fail(new McpCore.UnsupportedByProtocol({ protocolVersion, feature: "Client input" })) + // NOTE: Keep the two codec assertions below as the single documented // existential-schema boundary. Rpc.AnyWithProps intentionally erases each // request's payload type, while the runtime schema still performs decoding and diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts index 6de378ac13e..d029f5d6a50 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts @@ -255,6 +255,7 @@ export const protocol = McpProtocol.make({ { ...call, arguments: call.arguments ?? {} }, McpProtocol.invocationFromClient(request) ).pipe( + Effect.flatMap((outcome) => McpProtocol.requireCompleteOperation(McpSchema.protocolVersion, outcome)), Effect.mapError(McpProtocol.ProtocolError.fromTool) ) const content = yield* Effect.forEach(result.content, projectContent).pipe( diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts index 039c7d31596..7296d5f46cf 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts @@ -281,6 +281,7 @@ export const protocol = McpProtocol.make({ { ...call, arguments: call.arguments ?? {} }, McpProtocol.invocationFromClient(request) ).pipe( + Effect.flatMap((outcome) => McpProtocol.requireCompleteOperation(McpSchema.protocolVersion, outcome)), Effect.mapError(McpProtocol.ProtocolError.fromTool) ) const content = yield* Effect.forEach(result.content, projectContent).pipe( diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts index fb70aa1ce10..b5dc8d8d837 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -274,6 +274,7 @@ export const protocol = McpProtocol.make({ { ...call, arguments: call.arguments ?? {} }, McpProtocol.invocationFromClient(request) ).pipe( + Effect.flatMap((outcome) => McpProtocol.requireCompleteOperation(McpSchema.protocolVersion, outcome)), Effect.mapError(McpProtocol.ProtocolError.fromTool) ) const content = yield* Effect.forEach(result.content, projectContent).pipe( diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts index d56f31c9873..92bdde48e95 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts @@ -300,6 +300,7 @@ export const protocol = McpProtocol.make({ { ...call, arguments: call.arguments ?? {} }, McpProtocol.invocationFromClient(request) ).pipe( + Effect.flatMap((outcome) => McpProtocol.requireCompleteOperation(McpSchema.protocolVersion, outcome)), Effect.catchTag("InvalidToolInput", (error) => Effect.succeed(PublicMcpSchema.CallToolResult.make({ content: [PublicMcpSchema.TextContent.make({ diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts index 17ea1992df8..ac4fdb0c77e 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts @@ -13,9 +13,12 @@ import * as McpProtocol from "../mcpProtocol.ts" import * as McpSchema from "../mcpSchema/v2026_07_28.ts" const JsonObject = Schema.Record(Schema.String, Schema.Json) +const InputResponses = Schema.Record(Schema.String, JsonObject) const decodeRequestMetadata = Schema.decodeUnknownEffect(McpSchema.RequestMetaObject) -const omitUndefined = (value: Readonly>): Record => { +type ObjectWithUndefined = Readonly> + +const omitUndefined = (value: ObjectWithUndefined): Record => { const result: Record = {} for (const key in value) { if (value[key] !== undefined) { @@ -44,16 +47,41 @@ export const profileFromRequestMetadata = Effect.fnUntraced(function*(metadata: }) const resultMetadata = ( - value: Readonly>, + value: ObjectWithUndefined, serverInfo: Schema.JsonObject ): Schema.JsonObject => { - const metadata = value._meta + const metadata: unknown = value._meta return { ...(Schema.is(JsonObject)(metadata) ? metadata : {}), "io.modelcontextprotocol/serverInfo": serverInfo } } +const decodeCallToolOutcome = Schema.decodeUnknownEffect(Schema.Union([ + McpSchema.CallToolResult, + McpSchema.InputRequiredResult +])) + +export const projectCallToolOutcome = Effect.fnUntraced(function*( + outcome: McpCore.OperationOutcome, + serverInfo: typeof McpSchema.Implementation.Type +) { + const encodedServerInfo = yield* Schema.encodeEffect(McpSchema.Implementation)(serverInfo) + if (outcome._tag === "Complete") { + return yield* decodeCallToolOutcome({ + ...omitUndefined(outcome.value), + _meta: resultMetadata(outcome.value, encodedServerInfo), + resultType: "complete" + }) + } + return yield* decodeCallToolOutcome({ + _meta: { "io.modelcontextprotocol/serverInfo": encodedServerInfo }, + resultType: "input_required", + ...(outcome.inputRequests === undefined ? {} : { inputRequests: outcome.inputRequests }), + ...(outcome.requestState === undefined ? {} : { requestState: outcome.requestState }) + }) +}) + const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSchema.ContentBlock.Type) { return Match.value(content).pipe( Match.when({ type: Match.is("text", "resource_link") }, (content) => content), @@ -111,7 +139,7 @@ const privateStaleCache = { } satisfies { readonly ttlMs: number; readonly cacheScope: "private" } const projectCompleteResult = Effect.fnUntraced(function*( - value: Readonly>, + value: ObjectWithUndefined, serverInfo: typeof McpSchema.Implementation.Type ) { const encodedServerInfo = yield* Schema.encodeEffect(McpSchema.Implementation)(serverInfo) @@ -199,6 +227,20 @@ export const makeHandlers = ( const getInvocation = PublicMcpSchema.McpRequestContext.useSync( McpProtocol.invocationFromRequestContext ) + const getInputInvocation = Effect.fnUntraced(function*( + request: Pick + ) { + const context = yield* PublicMcpSchema.McpRequestContext + const inputResponses = request.inputResponses === undefined + ? undefined + // The RPC payload already validated the dated response union; this decode only erases it into canonical JSON. + : yield* Schema.decodeUnknownEffect(InputResponses)(request.inputResponses).pipe(Effect.orDie) + return McpProtocol.invocationFromRequestContext(PublicMcpSchema.McpRequestContext.of({ + ...context, + inputResponses, + requestState: request.requestState + })) + }) return ({ "server/discover": Effect.fnUntraced(function*( _request: typeof McpSchema.Discover.payloadSchema.Type @@ -323,32 +365,37 @@ export const makeHandlers = ( }, discovery.serverInfo) return yield* Schema.decodeUnknownEffect(McpSchema.ListToolsResult)(result) }, Effect.mapError(projectError)), - "tools/call": Effect.fnUntraced(function*( - { arguments: args, name }: typeof McpSchema.CallTool.payloadSchema.Type - ) { - const invocation = yield* getInvocation - const toolResult = yield* core.tools.call({ name, arguments: args ?? {} }, invocation).pipe( - Effect.catchTags({ - InvalidToolInput: (error) => - Effect.succeed(PublicMcpSchema.CallToolResult.make({ - content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], - isError: true - })), - ToolExecutionError: (error) => - Effect.succeed(PublicMcpSchema.CallToolResult.make({ - content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], - isError: true - })) - }) - ) + "tools/call": Effect.fnUntraced(function*(request: typeof McpSchema.CallTool.payloadSchema.Type) { + const invocation = yield* getInputInvocation(request) + const outcome = yield* core.tools.call({ name: request.name, arguments: request.arguments ?? {} }, invocation) + .pipe( + Effect.catchTags({ + InvalidToolInput: (error) => + Effect.succeed(McpCore.OperationOutcome.Complete(PublicMcpSchema.CallToolResult.make({ + content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], + isError: true + }))), + ToolExecutionError: (error) => + Effect.succeed(McpCore.OperationOutcome.Complete(PublicMcpSchema.CallToolResult.make({ + content: [PublicMcpSchema.TextContent.make({ type: "text", text: error.message })], + isError: true + }))) + }) + ) + if (outcome._tag === "InputRequired") { + return yield* projectCallToolOutcome(outcome, discovery.serverInfo) + } + const toolResult = outcome.value const content = yield* Effect.forEach(toolResult.content, projectContent) - const result = yield* projectCompleteResult({ - content, - structuredContent: toolResult.structuredContent, - isError: toolResult.isError, - _meta: toolResult._meta - }, discovery.serverInfo) - return yield* Schema.decodeUnknownEffect(McpSchema.CallToolResult)(result) + return yield* projectCallToolOutcome( + McpCore.OperationOutcome.Complete({ + content, + structuredContent: toolResult.structuredContent, + isError: toolResult.isError, + _meta: toolResult._meta + }), + discovery.serverInfo + ) }, Effect.mapError(projectError)), "notifications/cancelled": Effect.fnUntraced(function*( _request: typeof McpSchema.CancelledNotification.payloadSchema.Type diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts index e5353e57401..8b052ce5b40 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts @@ -17,6 +17,9 @@ export interface Observations { readonly resourceTemplateInvocations: number } +export const MrtrToolName = "MrtrTool" +export const mrtrRequestState = "opaque:+/=\u0000é" + const TestTool = Tool.make("TestTool", { description: "A test tool", parameters: Schema.Struct({ @@ -145,6 +148,66 @@ const makeContentToolsLayer = Layer.effectDiscard( }) ) +const mrtrToolLayer = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: MrtrToolName, + description: "Requests confirmation before completing", + inputSchema: { type: "object" } + }), + annotations: Context.make( + McpSchema.EnabledWhen, + (client) => client.protocolVersion === "2026-07-28" + ), + handle: () => + McpSchema.McpRequestContext.useSync((context) => { + const approval = context.inputResponses?.approval + const sample = context.inputResponses?.sample + const roots = context.inputResponses?.roots + if ( + context.requestState === mrtrRequestState && approval?.action === "accept" && + sample !== undefined && roots !== undefined + ) { + return new McpSchema.CallToolResult({ + content: [{ + type: "text", + text: JSON.stringify({ approval: approval.content, sample, roots }) + }] + }) + } + return new McpSchema.InputRequired({ + inputRequests: { + approval: { + method: "elicitation/create", + params: { + message: "Approve the operation", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + required: ["approved"] + } + } + }, + sample: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Suggest a title" } }], + maxTokens: 20 + } + }, + roots: { + method: "roots/list" + } + }, + requestState: mrtrRequestState + }) + }) + }) + }) +) + const templatePath = McpSchema.param("path", Schema.String) const TestResourceTemplate = McpServer.resource`file:///template/${templatePath}`({ name: "TestResourceTemplate", @@ -225,6 +288,7 @@ export const makeFeaturesServerLayer = ( Layer.mergeAll( makeTestToolkitLayer(observations, protocol.protocolVersion), makeContentToolsLayer, + mrtrToolLayer, McpServer.resource({ uri: "file:///test", name: "TestResource", diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts new file mode 100644 index 00000000000..948e8b20f28 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts @@ -0,0 +1,139 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import type * as McpProtocol from "effect/unstable/ai/McpProtocol" +import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" +import { mrtrRequestState, MrtrToolName } from "./McpConformanceFixtures.ts" + +const decodeInputRequired = Schema.decodeUnknownEffect(Schema.Struct({ + resultType: Schema.Literal("input_required"), + inputRequests: Schema.Record(Schema.String, Schema.Unknown), + requestState: Schema.String +})) + +const decodeComplete = Schema.decodeUnknownEffect(Schema.Struct({ + resultType: Schema.Literal("complete"), + content: Schema.Array(Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String + })) +})) + +const inputResponses = { + approval: { action: "accept", content: { approved: true } }, + sample: { + role: "assistant", + content: { type: "text", text: "Suggested title", _meta: {} }, + model: "fixture-model", + _meta: {} + }, + roots: { roots: [{ uri: "file:///workspace" }] } +} as const + +export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Multi round-trip requests", () => { + // SEP-2322: https://modelcontextprotocol.io/seps/2322-MRTR + // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow + it.effect("should return supported keyed input requests and resume when matching responses are supplied", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const firstResponse = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: MrtrToolName, arguments: {} } + }) + const first = yield* test.decodeResult(firstResponse).pipe( + Effect.flatMap((message) => decodeInputRequired(message.result)) + ) + + assert.strictEqual(first.requestState, mrtrRequestState) + assert.deepStrictEqual(first.inputRequests, { + approval: { + method: "elicitation/create", + params: { + message: "Approve the operation", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + required: ["approved"] + } + } + }, + sample: { + method: "sampling/createMessage", + params: { + messages: [{ role: "user", content: { type: "text", text: "Suggest a title" } }], + maxTokens: 20 + } + }, + roots: { + method: "roots/list" + } + }) + + const completedResponse = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: MrtrToolName, + arguments: {}, + inputResponses, + requestState: first.requestState + } + }) + const completed = yield* test.decodeResult(completedResponse).pipe( + Effect.flatMap((message) => decodeComplete(message.result)) + ) + + const content = completed.content[0] + assert.isDefined(content) + assert.deepStrictEqual(JSON.parse(content.text), { + approval: { approved: true }, + sample: { + role: "assistant", + content: { type: "text", text: "Suggested title", _meta: {} }, + model: "fixture-model", + _meta: {} + }, + roots: { roots: [{ uri: "file:///workspace" }] } + }) + })) + + it.effect("should remain input-required when continuation keys or request state do not match", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const cases = [ + { + inputResponses: { other: { action: "accept", content: { approved: true } } }, + requestState: mrtrRequestState + }, + { + inputResponses, + requestState: `${mrtrRequestState}:mismatch` + } + ] as const + + for (const [index, continuation] of cases.entries()) { + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: index + 10, + method: "tools/call", + params: { + name: MrtrToolName, + arguments: {}, + ...continuation + } + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeInputRequired(message.result)) + ) + assert.strictEqual(result.requestState, mrtrRequestState) + } + })) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts b/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts new file mode 100644 index 00000000000..f356aecded6 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts @@ -0,0 +1,7 @@ +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import { layer as makeMcpConformanceLayer } from "./McpConformance/McpConformance.ts" +import * as MultiRoundTripTest from "./McpConformance/MultiRoundTripTest.ts" + +const protocol = McpProtocol.v2026_07_28 + +MultiRoundTripTest.suite(protocol, makeMcpConformanceLayer(protocol)) diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts index 0aa589c5fbd..8484c3f09f3 100644 --- a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -125,6 +125,12 @@ const ResourceLinkPrompt = McpServer.prompt({ }]) }) +const MrtrTool = new McpSchema.Tool({ + name: "mrtr-elicitation", + description: "Requires client input before completing", + inputSchema: { type: "object" } +}) + interface TestState { sharedInvocations: number structuredInvocations: number @@ -163,8 +169,24 @@ const makeFixture = Effect.fnUntraced(function*() { "capability-gated": () => Effect.succeed("visible") }))) ) + const mrtrToolLayer = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool({ + tool: MrtrTool, + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.InputRequired({ + inputRequests: { roots: { method: "roots/list" } } + }) + ) + }) + }) + ) const serverLayer = Layer.mergeAll( toolkitLayer, + mrtrToolLayer, FamilyResource, FamilyPrompt, AudioPrompt, @@ -806,6 +828,15 @@ describe("McpServer protocol adapters", () => { } })) + it.effect("should reject client input when the selected protocol cannot encode it", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const legacy = yield* initialize(fixture.post, "2025-11-25") + const legacyError = errorOf(yield* legacy.request("tools/call", { name: MrtrTool.name, arguments: {} })) + assert.strictEqual(legacyError.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.match(legacyError.message, /Client input is not supported/) + })) + it.effect("should reject prompt content when the negotiated schema cannot represent it", () => Effect.gen(function*() { const fixture = yield* makeFixture() diff --git a/packages/effect/typetest/unstable/ai/McpServer.tst.ts b/packages/effect/typetest/unstable/ai/McpServer.tst.ts index 18dc5576934..5846d3feefa 100644 --- a/packages/effect/typetest/unstable/ai/McpServer.tst.ts +++ b/packages/effect/typetest/unstable/ai/McpServer.tst.ts @@ -113,6 +113,26 @@ describe("McpServer", () => { }) describe("request context", () => { + it("should expose multi-round-trip input through the request context", () => { + expect(McpSchema.McpRequestContext.useSync((context) => context.inputResponses)).type.toBe< + Effect.Effect< + Readonly> | undefined, + never, + McpSchema.McpRequestContext + > + >() + expect(McpSchema.McpRequestContext.useSync((context) => context.requestState)).type.toBe< + Effect.Effect + >() + expect( + new McpSchema.InputRequired({ + inputRequests: { + approval: { method: "elicitation/create", params: { message: "Approve" } } + } + }) + ).type.toBe() + }) + it("should expose the selected protocol version when a handler reads its client", () => { expect(McpSchema.McpServerClient.useSync((client) => client.protocolVersion)).type.toBe< Effect.Effect From b0153b6cf9362b3f354e6d4aac66f534c8b19054 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 15 Aug 2026 13:17:21 +0200 Subject: [PATCH 4/9] feat: support RPC HTTP streaming and server notifications --- .changeset/tidy-rpcs-stream.md | 5 + .../effect/src/unstable/rpc/RpcMessage.ts | 13 ++ .../src/unstable/rpc/RpcSerialization.ts | 13 ++ packages/effect/src/unstable/rpc/RpcServer.ts | 159 ++++++++++--- .../effect/test/rpc/RpcSerialization.test.ts | 31 +++ packages/effect/test/rpc/RpcServer.test.ts | 220 +++++++++++++++++- .../typetest/unstable/rpc/RpcServer.tst.ts | 20 +- 7 files changed, 422 insertions(+), 39 deletions(-) create mode 100644 .changeset/tidy-rpcs-stream.md diff --git a/.changeset/tidy-rpcs-stream.md b/.changeset/tidy-rpcs-stream.md new file mode 100644 index 00000000000..39ab01b1693 --- /dev/null +++ b/.changeset/tidy-rpcs-stream.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add request-selected streaming responses to the RPC HTTP protocol and typed server-to-client notifications for JSON-RPC HTTP and stdio transports. diff --git a/packages/effect/src/unstable/rpc/RpcMessage.ts b/packages/effect/src/unstable/rpc/RpcMessage.ts index dca41316dec..70a466eb0ef 100644 --- a/packages/effect/src/unstable/rpc/RpcMessage.ts +++ b/packages/effect/src/unstable/rpc/RpcMessage.ts @@ -69,6 +69,19 @@ export interface RequestEncoded { readonly sampled?: boolean } +/** + * A transport-encoded notification sent from an RPC server without a request + * identifier. Serializations that support server notifications can encode this + * envelope using their native notification representation. + * + * @category models + * @since 4.0.0 + */ +export interface ServerNotificationEncoded { + readonly tag: string + readonly payload: unknown +} + /** * The decoded RPC request envelope for an RPC union, carrying a branded request * id, typed RPC tag, decoded payload, headers, and optional trace context. diff --git a/packages/effect/src/unstable/rpc/RpcSerialization.ts b/packages/effect/src/unstable/rpc/RpcSerialization.ts index 6bfa6587c65..71579aca71f 100644 --- a/packages/effect/src/unstable/rpc/RpcSerialization.ts +++ b/packages/effect/src/unstable/rpc/RpcSerialization.ts @@ -34,6 +34,9 @@ export class RpcSerialization extends Context.Service Uint8Array | string }>()("effect/rpc/RpcSerialization") {} /** @@ -184,6 +187,7 @@ export const jsonRpc = (options?: { RpcSerialization.of({ contentType: options?.contentType ?? "application/json", includesFraming: false, + encodeNotification: (notification) => JSON.stringify(encodeJsonRpcNotification(notification)), makeUnsafe: () => { const decoder = new TextDecoder() const batches = new Map `${JSON.stringify(encodeJsonRpcNotification(notification))}\n`, makeUnsafe: () => { const parser = makeNdjson({ maxBufferSize: options?.maxBufferSize }).makeUnsafe() const batches = new Map ) => Effect.Effect + readonly sendNotification?: ( + clientId: number, + notification: ServerNotificationEncoded + ) => Effect.Effect readonly end: (clientId: number) => Effect.Effect readonly clientIds: Effect.Effect> readonly initialMessage: Effect.Effect> @@ -964,25 +969,24 @@ export const layerProtocolWebsocket = (options: { } /** - * Creates an HTTP request/response server `Protocol` together with an HTTP - * effect that decodes the current request and streams or returns encoded RPC - * responses. + * Describes a request-selected streaming HTTP response. + * + * Each encoded RPC message is transformed with `frame`, and the response is + * served with `contentType`. * * @category protocols * @since 4.0.0 */ -export const makeProtocolWithHttpEffect: Effect.Effect< - { - readonly protocol: Protocol["Service"] - readonly httpEffect: Effect.Effect< - HttpServerResponse.HttpServerResponse, - never, - Scope.Scope | HttpServerRequest.HttpServerRequest - > - }, - never, - RpcSerialization.RpcSerialization -> = Effect.gen(function*() { +export interface HttpResponseStream { + readonly contentType: string + readonly frame: (data: Uint8Array | string) => Uint8Array | string +} + +const makeProtocolWithHttpEffectInternal = Effect.fn(function*(options?: { + readonly selectResponseStream: ( + messages: ReadonlyArray + ) => HttpResponseStream | undefined +}) { const serialization = yield* RpcSerialization.RpcSerialization const includesFraming = serialization.includesFraming const isBinary = !serialization.contentType.includes("json") @@ -997,6 +1001,9 @@ export const makeProtocolWithHttpEffect: Effect.Effect< type Client = { readonly write: (bytes: FromServerEncoded) => Effect.Effect + readonly writeNotification?: ( + notification: ServerNotificationEncoded + ) => Effect.Effect readonly end: Effect.Effect } const clients = new Map() @@ -1017,28 +1024,47 @@ export const makeProtocolWithHttpEffect: Effect.Effect< isBinary ? Effect.map(request.arrayBuffer, (buf) => new Uint8Array(buf)) : request.text ) const id = clientId++ - const queue = yield* Queue.make() const parser = serialization.makeUnsafe() + const encodeNotification = serialization.encodeNotification + let decoded: ReadonlyArray = [] + let decodeCause: unknown | undefined + // @effect-diagnostics-next-line tryCatchInEffectGen:off + try { + decoded = parser.decode(data) as ReadonlyArray + } catch (cause) { + decodeCause = cause + } + const responseStream = decodeCause === undefined ? options?.selectResponseStream(decoded) : undefined + const streamsResponse = includesFraming || responseStream !== undefined + const queue = yield* Queue.make() const requestIds: Array = [] const offer = (data: Uint8Array | string) => typeof data === "string" ? Queue.offer(queue, encoder.encode(data)) : Queue.offer(queue, data) + const encodeAndOffer = (encode: () => Uint8Array | string | undefined) => { + let encoded: Uint8Array | string | undefined + try { + encoded = encode() + } catch (cause) { + encoded = parser.encode(ResponseDefectEncoded(cause)) + } + if (encoded === undefined) return Effect.void + return responseStream === undefined + ? offer(encoded) + : Effect.sync(() => responseStream.frame(encoded)).pipe(Effect.flatMap(offer)) + } const client: Client = { - write: !includesFraming + write: !streamsResponse ? (response) => Queue.offer(queue, response) - : (response) => { - try { - const encoded = parser.encode(response) - if (encoded === undefined) return Effect.void - return offer(encoded) - } catch (cause) { - return offer(parser.encode(ResponseDefectEncoded(cause))!) - } - }, + : (response) => encodeAndOffer(() => parser.encode(response)), + ...(!streamsResponse || encodeNotification === undefined ? {} : { + writeNotification: (notification: ServerNotificationEncoded) => + encodeAndOffer(() => encodeNotification(notification)) + }), end: Queue.end(queue) } - yield* Scope.addFinalizerExit(scope, () => { + const cleanup = () => { clients.delete(id) clientIds.delete(id) Queue.offerUnsafe(disconnects, id) @@ -1048,13 +1074,12 @@ export const makeProtocolWithHttpEffect: Effect.Effect< (requestId) => writeRequest(id, { _tag: "Interrupt", requestId }), { discard: true } ) - }) + } + yield* Scope.addFinalizerExit(scope, cleanup) clients.set(id, client) clientIds.add(id) - // @effect-diagnostics-next-line tryCatchInEffectGen:off - try { - const decoded = parser.decode(data) as ReadonlyArray + if (decodeCause === undefined) { for (let i = 0; i < decoded.length; i++) { const message = decoded[i] if (message._tag === "Request") { @@ -1063,13 +1088,13 @@ export const makeProtocolWithHttpEffect: Effect.Effect< } yield* writeRequest(id, message) } - } catch (cause) { - yield* client.write(ResponseDefectEncoded(cause)) + } else { + yield* client.write(ResponseDefectEncoded(decodeCause)) } yield* writeRequest(id, constEof) - if (!includesFraming) { + if (!streamsResponse) { const responses = yield* Queue.collect(queue) return HttpServerResponse.text(parser.encode(responses) as string, { contentType: serialization.contentType @@ -1079,7 +1104,7 @@ export const makeProtocolWithHttpEffect: Effect.Effect< const initialChunk = yield* Queue.takeAll(queue) as any as Effect.Effect> if (queue.state._tag === "Done") { return HttpServerResponse.uint8Array(mergeUint8Arrays(initialChunk), { - contentType: serialization.contentType + contentType: responseStream?.contentType ?? serialization.contentType }) } @@ -1089,7 +1114,7 @@ export const makeProtocolWithHttpEffect: Effect.Effect< Stream.fromQueue(queue as Queue.Dequeue) ) ), - { contentType: serialization.contentType } + { contentType: responseStream?.contentType ?? serialization.contentType } ) }) @@ -1102,6 +1127,13 @@ export const makeProtocolWithHttpEffect: Effect.Effect< if (!client) return Effect.void return client.write(response) }, + ...(serialization.encodeNotification === undefined || (!includesFraming && options === undefined) ? {} : { + sendNotification(clientId: number, notification: ServerNotificationEncoded) { + const client = clients.get(clientId) + if (!client?.writeNotification) return Effect.void + return client.writeNotification(notification) + } + }), end(clientId) { const client = clients.get(clientId) if (!client) return Effect.void @@ -1118,6 +1150,53 @@ export const makeProtocolWithHttpEffect: Effect.Effect< return { protocol, httpEffect } as const }) +/** + * Creates an HTTP request/response server protocol that can select streaming + * response framing from the decoded messages in each request. + * + * Use `makeProtocolWithHttpEffect` when every request should use the + * serialization's default buffering or framing behavior. + * + * @category protocols + * @since 4.0.0 + */ +export const makeProtocolWithHttpEffectWith = (options: { + readonly selectResponseStream: ( + messages: ReadonlyArray + ) => HttpResponseStream | undefined +}): Effect.Effect< + { + readonly protocol: Protocol["Service"] + readonly httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + never, + Scope.Scope | HttpServerRequest.HttpServerRequest + > + }, + never, + RpcSerialization.RpcSerialization +> => makeProtocolWithHttpEffectInternal(options) + +/** + * Creates an HTTP request/response server `Protocol` using the serialization's + * default buffering or framing behavior. + * + * @category protocols + * @since 4.0.0 + */ +export const makeProtocolWithHttpEffect: Effect.Effect< + { + readonly protocol: Protocol["Service"] + readonly httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + never, + Scope.Scope | HttpServerRequest.HttpServerRequest + > + }, + never, + RpcSerialization.RpcSerialization +> = makeProtocolWithHttpEffectInternal() + const mergeUint8Arrays = (arrays: ReadonlyArray) => { if (arrays.length === 0) return new Uint8Array(0) if (arrays.length === 1) return arrays[0] @@ -1257,6 +1336,7 @@ export const makeProtocolStdio = Effect.gen(function*() { const stdio = yield* Stdio const fiber = Fiber.getCurrent()! const serialization = yield* RpcSerialization.RpcSerialization + const encodeNotification = serialization.encodeNotification return yield* Protocol.make(Effect.fnUntraced(function*(writeRequest) { const queue = yield* Queue.make() @@ -1295,6 +1375,11 @@ export const makeProtocolStdio = Effect.gen(function*() { } return Queue.offer(queue, responseEncoded) }, + ...(encodeNotification === undefined ? {} : { + sendNotification(_clientId: number, notification: ServerNotificationEncoded) { + return Queue.offer(queue, encodeNotification(notification)) + } + }), end(_clientId) { return Queue.end(queue) }, diff --git a/packages/effect/test/rpc/RpcSerialization.test.ts b/packages/effect/test/rpc/RpcSerialization.test.ts index 831d661a24c..291b7b7b7ee 100644 --- a/packages/effect/test/rpc/RpcSerialization.test.ts +++ b/packages/effect/test/rpc/RpcSerialization.test.ts @@ -244,6 +244,37 @@ describe("RpcSerialization", () => { ) }) + it("jsonRpc encodes server notifications without an id", () => { + const serialization = RpcSerialization.jsonRpc() + assert(serialization.encodeNotification !== undefined) + + const encoded = serialization.encodeNotification({ + tag: "resources/updated", + payload: { uri: "file:///resource" } + }) + + assert.strictEqual( + encoded, + JSON.stringify({ + jsonrpc: "2.0", + method: "resources/updated", + params: { uri: "file:///resource" } + }) + ) + }) + + it("ndJsonRpc frames server notifications once", () => { + const serialization = RpcSerialization.ndJsonRpc() + assert(serialization.encodeNotification !== undefined) + + const encoded = serialization.encodeNotification({ + tag: "tools/list_changed", + payload: null + }) + + assert.strictEqual(encoded, "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list_changed\",\"params\":null}\n") + }) + it("msgPack roundtrips an encoded RPC request envelope", () => { const parser = RpcSerialization.msgPack.makeUnsafe() const payload = { _tag: "Request", id: 1, method: "echo" } diff --git a/packages/effect/test/rpc/RpcServer.test.ts b/packages/effect/test/rpc/RpcServer.test.ts index e3a40b7b3d0..7af0d505c9e 100644 --- a/packages/effect/test/rpc/RpcServer.test.ts +++ b/packages/effect/test/rpc/RpcServer.test.ts @@ -1,9 +1,227 @@ import { assert, describe, it } from "@effect/vitest" -import { Deferred, Effect, Layer } from "effect" +import { Deferred, Effect, Layer, Queue, Sink, Stdio, Stream } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { RpcSerialization, RpcServer } from "effect/unstable/rpc" import { Socket, SocketServer } from "effect/unstable/socket" +const jsonRpcRequest = (body: string) => + HttpServerRequest.fromWeb( + new Request("http://localhost/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body + }) + ) + +const makeHttpProtocol = (options?: Parameters[0]) => + (options === undefined ? RpcServer.makeProtocolWithHttpEffect : RpcServer.makeProtocolWithHttpEffectWith(options)) + .pipe( + Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.jsonRpc()) + ) + +const respondToRequests = (protocol: RpcServer.Protocol["Service"]) => + protocol.run((clientId, message) => { + if (message._tag === "Request") { + return protocol.send(clientId, { + _tag: "Exit", + requestId: message.id, + exit: { _tag: "Success", value: "pong" } + }) + } + return message._tag === "Eof" ? protocol.end(clientId) : Effect.void + }) + +const responseText = (response: HttpServerResponse.HttpServerResponse) => + Effect.promise(() => HttpServerResponse.toWeb(response).text()) + describe("RpcServer", () => { + it.effect("should preserve buffered JSON-RPC responses when no response stream is configured", () => + Effect.gen(function*() { + const { httpEffect, protocol } = yield* makeHttpProtocol() + yield* respondToRequests(protocol).pipe(Effect.forkScoped) + + const response = yield* httpEffect.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + jsonRpcRequest( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "Ping", params: {} }) + ) + ) + ) + + assert.strictEqual(response.headers["content-type"], "application/json") + assert.deepStrictEqual(JSON.parse(yield* responseText(response)), { + jsonrpc: "2.0", + id: 1, + result: "pong" + }) + })) + + it.effect("should preserve buffered responses when the response stream selector returns undefined", () => + Effect.gen(function*() { + const selected: Array> = [] + const { httpEffect, protocol } = yield* makeHttpProtocol({ + selectResponseStream: (messages) => { + selected.push(messages.map((message) => message._tag)) + return undefined + } + }) + yield* respondToRequests(protocol).pipe(Effect.forkScoped) + + const response = yield* httpEffect.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + jsonRpcRequest( + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "Ping", params: {} }) + ) + ) + ) + + assert.deepStrictEqual(selected, [["Request"]]) + assert.deepStrictEqual(JSON.parse(yield* responseText(response)), { + jsonrpc: "2.0", + id: 2, + result: "pong" + }) + })) + + it.effect("should frame server notifications when the request selects a streaming response", () => + Effect.gen(function*() { + const { httpEffect, protocol } = yield* makeHttpProtocol({ + selectResponseStream: () => ({ + contentType: "text/event-stream", + frame: (data) => `data: ${data}\n\n` + }) + }) + yield* protocol.run((clientId, message) => { + if (message._tag === "Request") { + assert(protocol.sendNotification !== undefined) + return protocol.sendNotification(clientId, { + tag: "resources/updated", + payload: { uri: "file:///resource" } + }) + } + return message._tag === "Eof" ? protocol.end(clientId) : Effect.void + }).pipe(Effect.forkScoped) + + const response = yield* httpEffect.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + jsonRpcRequest( + JSON.stringify({ jsonrpc: "2.0", id: 3, method: "Listen", params: {} }) + ) + ) + ) + + assert.strictEqual(response.headers["content-type"], "text/event-stream") + assert.strictEqual( + yield* responseText(response), + `data: ${ + JSON.stringify({ + jsonrpc: "2.0", + method: "resources/updated", + params: { uri: "file:///resource" } + }) + }\n\n` + ) + })) + + it.effect("should omit server notifications from buffered HTTP responses", () => + Effect.gen(function*() { + const { httpEffect, protocol } = yield* makeHttpProtocol({ selectResponseStream: () => undefined }) + const sendNotification = protocol.sendNotification + assert(sendNotification !== undefined) + yield* protocol.run((clientId, message) => { + if (message._tag === "Request") { + return sendNotification(clientId, { + tag: "resources/updated", + payload: { uri: "file:///resource" } + }).pipe( + Effect.andThen(protocol.send(clientId, { + _tag: "Exit", + requestId: message.id, + exit: { _tag: "Success", value: "pong" } + })) + ) + } + return message._tag === "Eof" ? protocol.end(clientId) : Effect.void + }).pipe(Effect.forkScoped) + + const response = yield* httpEffect.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + jsonRpcRequest(JSON.stringify({ jsonrpc: "2.0", id: 4, method: "Ping", params: {} })) + ) + ) + + assert.deepStrictEqual(JSON.parse(yield* responseText(response)), { + jsonrpc: "2.0", + id: 4, + result: "pong" + }) + })) + + it.effect("should omit the notification capability from buffered JSON-RPC HTTP", () => + Effect.gen(function*() { + const { protocol } = yield* RpcServer.makeProtocolWithHttpEffect.pipe( + Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.jsonRpc()) + ) + + assert.isUndefined(protocol.sendNotification) + })) + + it.effect("should not select a response stream when request decoding fails", () => + Effect.gen(function*() { + let selectorInvoked = false + const { httpEffect, protocol } = yield* makeHttpProtocol({ + selectResponseStream: () => { + selectorInvoked = true + return undefined + } + }) + yield* respondToRequests(protocol).pipe(Effect.forkScoped) + + const response = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, jsonRpcRequest("{")) + ) + const body = JSON.parse(yield* responseText(response)) + + assert.isFalse(selectorInvoked) + assert.strictEqual(body.jsonrpc, "2.0") + assert.strictEqual(body.error._tag, "Defect") + })) + + it.effect("should write each server notification to stdio once", () => + Effect.gen(function*() { + const stdout = yield* Queue.unbounded() + const ready = yield* Deferred.make() + yield* Effect.gen(function*() { + const protocol = yield* RpcServer.makeProtocolStdio + yield* Deferred.succeed(ready, protocol) + return yield* Effect.never + }).pipe( + Effect.provide(Stdio.layerTest({ + stdin: Stream.never, + stdout: () => Sink.forEach((chunk) => Queue.offer(stdout, chunk)) + })), + Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndJsonRpc()), + Effect.forkScoped + ) + const protocol = yield* Deferred.await(ready) + assert(protocol.sendNotification !== undefined) + + yield* protocol.sendNotification(0, { + tag: "tools/list_changed", + payload: null + }) + + assert.strictEqual( + yield* Queue.take(stdout), + `${JSON.stringify({ jsonrpc: "2.0", method: "tools/list_changed", params: null })}\n` + ) + assert.isTrue((yield* Queue.poll(stdout))._tag === "None") + })) + it.effect("closes a socket when the serialization buffer limit is exceeded", () => Effect.gen(function*() { const handledChunks: Array = [] diff --git a/packages/effect/typetest/unstable/rpc/RpcServer.tst.ts b/packages/effect/typetest/unstable/rpc/RpcServer.tst.ts index 95ed9d0dea8..d758585a644 100644 --- a/packages/effect/typetest/unstable/rpc/RpcServer.tst.ts +++ b/packages/effect/typetest/unstable/rpc/RpcServer.tst.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Effect, Schema } from "effect" import * as Rpc from "effect/unstable/rpc/Rpc" import * as RpcGroup from "effect/unstable/rpc/RpcGroup" import * as RpcServer from "effect/unstable/rpc/RpcServer" @@ -6,8 +6,26 @@ import { describe, it } from "tstyche" const Ping = Rpc.make("Ping", { success: Schema.String }) const Group = RpcGroup.make(Ping) +declare const protocol: RpcServer.Protocol["Service"] describe("RpcServer", () => { + it("exposes additive HTTP streaming and server notification capabilities", () => { + const make = RpcServer.makeProtocolWithHttpEffectWith({ + selectResponseStream: (messages) => { + void messages + return { + contentType: "text/event-stream", + frame: (data) => data + } satisfies RpcServer.HttpResponseStream + } + }) + void make + void protocol.sendNotification?.(0, { + tag: "events/updated", + payload: { value: 1 } + }).pipe(Effect.asVoid) + }) + it("layerHttp accepts disableFatalDefects", () => { RpcServer.layerHttp({ group: Group, From 953821549d81f77d701a0a66b98dc08a11019a0c Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 15 Aug 2026 13:19:59 +0200 Subject: [PATCH 5/9] feat: add MCP 2026-07-28 subscriptions --- .changeset/yellow-dingos-stick.md | 5 + .../effect/src/unstable/ai/McpProtocol.ts | 6 +- packages/effect/src/unstable/ai/McpServer.ts | 48 +- .../src/unstable/ai/internal/mcpProtocol.ts | 38 ++ .../ai/internal/mcpProtocol/v2026_07_28.ts | 119 ++++- .../src/unstable/ai/internal/mcpRuntime.ts | 4 + .../ai/internal/mcpSchema/v2026_07_28.ts | 1 + .../McpConformance/SubscriptionsTest.ts | 475 ++++++++++++++++++ .../ai/McpServer/McpSubscriptions.test.ts | 7 + .../ai/McpServer/TestUtils/McpStdioHarness.ts | 95 +++- 10 files changed, 769 insertions(+), 29 deletions(-) create mode 100644 .changeset/yellow-dingos-stick.md create mode 100644 packages/effect/test/unstable/ai/McpServer/McpConformance/SubscriptionsTest.ts create mode 100644 packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts diff --git a/.changeset/yellow-dingos-stick.md b/.changeset/yellow-dingos-stick.md new file mode 100644 index 00000000000..55de3177721 --- /dev/null +++ b/.changeset/yellow-dingos-stick.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Long-lived MCP 2026-07-28 `subscriptions/listen` requests deliver filtered change notifications over stdio or Server-Sent Events while legacy notification behavior remains unchanged. diff --git a/packages/effect/src/unstable/ai/McpProtocol.ts b/packages/effect/src/unstable/ai/McpProtocol.ts index 992dafb6df1..31c53a6becb 100644 --- a/packages/effect/src/unstable/ai/McpProtocol.ts +++ b/packages/effect/src/unstable/ai/McpProtocol.ts @@ -186,9 +186,9 @@ export interface ProtocolAdapter< * This revision uses request-scoped metadata instead of initialization and * protocol-level sessions. * - * **Gotchas** - * - * Change-notification subscriptions are not currently advertised or served. + * When the selected transport supports server notifications, discovery + * advertises change-notification capabilities and `subscriptions/listen` + * delivers the requested notifications over the long-lived response. * * @category protocols * @since 4.0.0 diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 4d7251b075d..e4b93b9a95d 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -20,6 +20,7 @@ import * as Fiber from "../../Fiber.ts" import * as Layer from "../../Layer.ts" import * as Option from "../../Option.ts" import * as Predicate from "../../Predicate.ts" +import * as PubSub from "../../PubSub.ts" import * as Queue from "../../Queue.ts" import * as RcMap from "../../RcMap.ts" import { CurrentLogLevel } from "../../References.ts" @@ -663,8 +664,34 @@ const runWithRuntime = Effect.fnUntraced(function*(options: { const clientProtocols = new Map() const activeRequests = new Map>() const clientProfiles = new Map>() + // A bounded PubSub would let one slow listener block the shared worker and + // legacy delivery. Each request scope releases its subscription on exit. + const serverNotifications = yield* PubSub.unbounded() + const sendNotification = protocol.sendNotification const handlers = yield* runtime.installHandlers({ core: internalState.get(server)!.core, + subscribeServerNotifications: PubSub.subscribe(serverNotifications), + ...(sendNotification === undefined ? {} : { + sendNotification: ( + protocolVersion: string, + clientId: number, + notification: McpProtocol.ProjectedNotification + ) => + Effect.gen(function*() { + const selectedProtocol = runtime.selectProtocol(protocolVersion) + const rpc = selectedProtocol.serverNotificationRpcs.requests.get(notification.tag) + if (rpc === undefined) { + return yield* Effect.die( + `MCP protocol ${protocolVersion} does not define server notification ${notification.tag}` + ) + } + const payload = yield* selectedProtocol.payloadCodecs(rpc).encode(notification.payload) + yield* sendNotification(clientId, { + tag: notification.tag, + payload + }) + }).pipe(Effect.orDie) + }), defaultLogLevel, serverInfo: options }) @@ -1057,6 +1084,9 @@ const runWithRuntime = Effect.fnUntraced(function*(options: { yield* Queue.take(internalState.get(server)!.notifications).pipe( Effect.flatMap(Effect.fnUntraced(function*({ notification, targetClientId }) { + if (McpProtocolInternal.isSubscriptionServerNotification(notification)) { + yield* PubSub.publish(serverNotifications, { notification, targetClientId }) + } const clientIds = yield* patchedProtocol.clientIds for (const clientId of clientProtocols.keys()) { if (!clientIds.has(clientId)) { @@ -1221,9 +1251,14 @@ const mcpStdioSerialization = ( const serialization = RpcSerialization.jsonRpc({ contentType: "application/json-rpc" }) + const encodeNotification = serialization.encodeNotification return RpcSerialization.RpcSerialization.of({ contentType: serialization.contentType, includesFraming: true, + ...(encodeNotification === undefined ? {} : { + encodeNotification: (notification: RpcMessage.ServerNotificationEncoded) => + `${encodeNotification(notification)}\n` + }), makeUnsafe: () => { const frames = RpcSerialization.ndjson.makeUnsafe() const parser = serialization.makeUnsafe() @@ -1353,7 +1388,18 @@ const layerMcpProtocolHttp = (options: { > => Layer.effect(RpcServer.Protocol)(Effect.gen(function*() { const runtime = yield* McpRuntime.ServerRuntime - const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect + const textDecoder = new TextDecoder() + const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffectWith({ + selectResponseStream: (messages) => + messages.length === 1 && messages[0]._tag === "Request" && + messages[0].tag === "subscriptions/listen" + ? { + contentType: "text/event-stream", + frame: (data: Uint8Array | string) => + `data: ${typeof data === "string" ? data : textDecoder.decode(data)}\n\n` + } + : undefined + }) const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, (request) => { if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index 5678dfd20ed..258b3cb2032 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -1,6 +1,7 @@ import * as Data from "../../../Data.ts" import * as Effect from "../../../Effect.ts" import * as Match from "../../../Match.ts" +import type * as PubSub from "../../../PubSub.ts" import * as Result from "../../../Result.ts" import * as Schema from "../../../Schema.ts" import type * as Scope from "../../../Scope.ts" @@ -250,6 +251,16 @@ export interface HandlerInstallationTarget { /** @internal */ export interface HandlerInstallationContext { + readonly subscribeServerNotifications: Effect.Effect< + PubSub.Subscription, + never, + Scope.Scope + > + readonly sendNotification?: ( + protocolVersion: string, + clientId: number, + notification: PublicMcpProtocol.ProjectedNotification + ) => Effect.Effect readonly supportedVersions: ReadonlyArray readonly serverInfo: { readonly name: string @@ -265,6 +276,33 @@ export interface HandlerInstallationContext { } } +/** @internal */ +export interface CanonicalServerNotification { + readonly notification: SubscriptionServerNotification + readonly targetClientId?: number | undefined +} + +/** @internal */ +export type SubscriptionServerNotification = Extract< + McpCore.ServerNotification, + { readonly _tag: "ToolsChanged" | "PromptsChanged" | "ResourcesChanged" | "ResourceUpdated" } +> + +/** @internal */ +export const isSubscriptionServerNotification = ( + notification: McpCore.ServerNotification +): notification is SubscriptionServerNotification => { + switch (notification._tag) { + case "ToolsChanged": + case "PromptsChanged": + case "ResourcesChanged": + case "ResourceUpdated": + return true + default: + return false + } +} + /** @internal */ export interface ProtocolAdapter< out Version extends string = string, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts index ac4fdb0c77e..1ce9d5ce4e2 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts @@ -6,7 +6,10 @@ import * as Effect from "../../../../Effect.ts" import * as Encoding from "../../../../Encoding.ts" import * as Match from "../../../../Match.ts" +import * as PubSub from "../../../../PubSub.ts" import * as Schema from "../../../../Schema.ts" +import type * as Rpc from "../../../rpc/Rpc.ts" +import type * as RpcMessage from "../../../rpc/RpcMessage.ts" import * as PublicMcpSchema from "../../McpSchema.ts" import * as McpCore from "../mcpCore.ts" import * as McpProtocol from "../mcpProtocol.ts" @@ -213,14 +216,27 @@ export const makeHandlers = ( _lifecycle: McpProtocol.LifecycleRuntime | undefined, context: McpProtocol.HandlerInstallationContext ) => { + const sendNotification = context.sendNotification + const supportsSubscriptions = sendNotification !== undefined const discovery: ServerDiscoveryContext = { supportedVersions: context.supportedVersions, capabilities: { completions: {}, logging: {}, - ...(context.registrationPresence.tools ? { tools: { listChanged: false } } : {}), - ...(context.registrationPresence.resources ? { resources: { listChanged: false, subscribe: false } } : {}), - ...(context.registrationPresence.prompts ? { prompts: { listChanged: false } } : {}) + ...(context.registrationPresence.tools + ? { tools: { listChanged: supportsSubscriptions } } + : {}), + ...(context.registrationPresence.resources + ? { + resources: { + listChanged: supportsSubscriptions, + subscribe: supportsSubscriptions + } + } + : {}), + ...(context.registrationPresence.prompts + ? { prompts: { listChanged: supportsSubscriptions } } + : {}) }, serverInfo: context.serverInfo } @@ -242,6 +258,92 @@ export const makeHandlers = ( })) }) return ({ + "subscriptions/listen": Effect.fnUntraced(function*( + request: typeof McpSchema.SubscriptionsListen.payloadSchema.Type, + { client, requestId }: { readonly client: Rpc.ServerClient; readonly requestId: RpcMessage.RequestId } + ) { + if (sendNotification === undefined) { + return yield* Effect.fail(McpSchema.McpError.make({ + code: McpSchema.METHOD_NOT_FOUND, + message: "Method not found: subscriptions/listen" + })) + } + const events = yield* context.subscribeServerNotifications + const honored = { + ...(context.registrationPresence.tools && request.notifications.toolsListChanged === true + ? { toolsListChanged: true } + : {}), + ...(context.registrationPresence.prompts && request.notifications.promptsListChanged === true + ? { promptsListChanged: true } + : {}), + ...(context.registrationPresence.resources && request.notifications.resourcesListChanged === true + ? { resourcesListChanged: true } + : {}), + ...(context.registrationPresence.resources && request.notifications.resourceSubscriptions !== undefined + ? { resourceSubscriptions: request.notifications.resourceSubscriptions } + : {}) + } + const subscriptionMetadata = { "io.modelcontextprotocol/subscriptionId": requestId } + yield* sendNotification(McpSchema.protocolVersion, client.id, { + tag: McpSchema.SubscriptionsAcknowledgedNotification._tag, + payload: McpSchema.SubscriptionsAcknowledgedNotification.payloadSchema.make({ + _meta: subscriptionMetadata, + notifications: honored + }) + }) + return yield* Effect.forever(Effect.gen(function*() { + const event = yield* PubSub.take(events) + if (event.targetClientId !== undefined && event.targetClientId !== client.id) { + return + } + const notification = event.notification + const projected = Match.value(notification).pipe( + Match.tags({ + ToolsChanged: (notification) => + honored.toolsListChanged === true ? + { + tag: McpSchema.ToolListChangedNotification._tag, + payload: McpSchema.ToolListChangedNotification.payloadSchema.make({ + _meta: { ...notification.metadata, ...subscriptionMetadata } + }) + } : + undefined, + PromptsChanged: (notification) => + honored.promptsListChanged === true ? + { + tag: McpSchema.PromptListChangedNotification._tag, + payload: McpSchema.PromptListChangedNotification.payloadSchema.make({ + _meta: { ...notification.metadata, ...subscriptionMetadata } + }) + } : + undefined, + ResourcesChanged: (notification) => + honored.resourcesListChanged === true ? + { + tag: McpSchema.ResourceListChangedNotification._tag, + payload: McpSchema.ResourceListChangedNotification.payloadSchema.make({ + _meta: { ...notification.metadata, ...subscriptionMetadata } + }) + } : + undefined, + ResourceUpdated: (notification) => + honored.resourceSubscriptions?.includes(notification.uri) === true ? + { + tag: McpSchema.ResourceUpdatedNotification._tag, + payload: McpSchema.ResourceUpdatedNotification.payloadSchema.make({ + _meta: { ...notification.metadata, ...subscriptionMetadata }, + uri: notification.uri + }) + } : + undefined + }), + Match.exhaustive + ) + if (projected !== undefined) { + yield* sendNotification(McpSchema.protocolVersion, client.id, projected) + } + })) + }), "server/discover": Effect.fnUntraced(function*( _request: typeof McpSchema.Discover.payloadSchema.Type ) { @@ -428,12 +530,11 @@ export const protocol = McpProtocol.make({ createMessage: () => Effect.fail(unsupported("sampling/createMessage")), elicit: () => Effect.fail(unsupported("elicitation/create")) }), - // TODO: Route change notifications through subscriptions/listen before - // advertising them. The runtime must filter each subscription and add its - // subscription ID to notification _meta before transport delivery. projectNotification: (notification) => - McpProtocol.makeNotificationProjector({ - supportsProgressMessage: true - }, notification), + McpProtocol.isSubscriptionServerNotification(notification) + ? Effect.succeed(undefined) + : McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), normalizeCancellation }) diff --git a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts index ba4a759174b..96eb4e21574 100644 --- a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts +++ b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts @@ -117,6 +117,8 @@ export type HttpAdmission = /** @internal */ export interface HandlerInstallationOptions { readonly core: McpCore.McpCore + readonly subscribeServerNotifications: McpProtocol.HandlerInstallationContext["subscribeServerNotifications"] + readonly sendNotification?: NonNullable readonly defaultLogLevel: LogLevel.LogLevel readonly serverInfo: { readonly name: string @@ -350,6 +352,8 @@ export const make = Effect.fnUntraced(function*( const contextMap = new Map() const registrationPresence = yield* options.core.registrationPresence const installationContext: McpProtocol.HandlerInstallationContext = { + subscribeServerNotifications: options.subscribeServerNotifications, + ...(options.sendNotification === undefined ? {} : { sendNotification: options.sendNotification }), supportedVersions: protocolVersions, serverInfo: options.serverInfo, registrationPresence diff --git a/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts index 8b5fa5cd790..c973f79d42a 100644 --- a/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts +++ b/packages/effect/src/unstable/ai/internal/mcpSchema/v2026_07_28.ts @@ -565,6 +565,7 @@ export class ClientRequestRpcs extends RpcGroup.make( ListResources, ListResourceTemplates, ReadResource, + SubscriptionsListen, CallTool, ListTools ) {} diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/SubscriptionsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/SubscriptionsTest.ts new file mode 100644 index 00000000000..980d6a71a88 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/SubscriptionsTest.ts @@ -0,0 +1,475 @@ +import { assert, describe, it } from "@effect/vitest" +import type * as Arr from "effect/Array" +import * as Context from "effect/Context" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Predicate from "effect/Predicate" +import * as Queue from "effect/Queue" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as McpServer from "effect/unstable/ai/McpServer" +import type * as RpcMessage from "effect/unstable/rpc/RpcMessage" +import * as RpcServer from "effect/unstable/rpc/RpcServer" +import { makeHttpHarness } from "../TestUtils/McpHttpHarness.ts" +import { makeServerLayer } from "../TestUtils/McpServerLayer.ts" +import { type JsonRpcMessage, makeMcpStdioHarness, type McpStdioHarness } from "../TestUtils/McpStdioHarness.ts" +import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" + +const subscriptionIdKey = "io.modelcontextprotocol/subscriptionId" + +const paramsOf = (message: JsonRpcMessage): Record => { + assert.isObject(message.params) + return message.params as Record +} + +const subscriptionIdOf = (message: JsonRpcMessage): string | number => { + const params = paramsOf(message) + assert.isObject(params._meta) + const subscriptionId = (params._meta as Record)[subscriptionIdKey] + if (typeof subscriptionId === "string" || typeof subscriptionId === "number") { + return subscriptionId + } + return assert.fail("Expected a string or numeric subscription identifier") +} + +const makeTool = (name: string) => ({ + tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }), + annotations: Context.empty(), + handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] })) +}) + +const makePrompt = (name: string) => ({ + prompt: new McpSchema.Prompt({ name }), + annotations: Context.empty(), + completions: {}, + handle: () => + Effect.succeed( + new McpSchema.GetPromptResult({ + messages: [{ role: "user", content: { type: "text", text: name } }] + }) + ) +}) + +const makeResource = (uri: string) => ({ + resource: new McpSchema.Resource({ uri, name: uri }), + annotations: Context.empty(), + handle: Effect.succeed(McpSchema.ReadResourceResult.make({ contents: [] })) +}) + +const subscriptionRegistrations = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool(makeTool("subscription-registration-tool")) + yield* server.addPrompt(makePrompt("subscription-registration-prompt")) + yield* server.addResource(makeResource("file:///subscription-registration-resource")) + }) +) + +const toolOnlyRegistration = Layer.effectDiscard( + McpServer.McpServer.use((server) => server.addTool(makeTool("tool-only-registration"))) +) + +const makeSubscriptionHarness = ( + protocol: McpProtocol.ProtocolAdapter, + protocols: Arr.NonEmptyReadonlyArray = [protocol] +) => makeMcpStdioHarness(protocol, protocols, subscriptionRegistrations) + +const listen = ( + fixture: McpStdioHarness, + id: string | number, + notifications: Record +) => + fixture.flushListChanged.pipe( + Effect.andThen(fixture.startRequest("subscriptions/listen", { notifications }, id)) + ) + +const assertAcknowledged = ( + message: JsonRpcMessage, + id: string | number, + notifications: Record +) => { + assert.strictEqual(message.method, "notifications/subscriptions/acknowledged") + assert.notProperty(message, "id") + assert.strictEqual(subscriptionIdOf(message), id) + assert.deepStrictEqual(paramsOf(message).notifications, notifications) +} + +const makeSseReader = (response: Response) => { + assert.match(response.headers.get("content-type") ?? "", /^text\/event-stream(?:;|$)/) + const body = response.body + assert.isNotNull(body) + const reader = body.getReader() + const decoder = new TextDecoder() + let pending = "" + const take = Effect.fnUntraced(function*() { + while (true) { + const boundary = pending.indexOf("\n\n") + if (boundary !== -1) { + const event = pending.slice(0, boundary) + pending = pending.slice(boundary + 2) + const data = event.split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n") + if (data.length > 0) { + return JSON.parse(data) as JsonRpcMessage + } + continue + } + const chunk = yield* Effect.promise(() => reader.read()) + assert.isFalse(chunk.done) + pending += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n") + } + }) + return { + take, + cancel: Effect.promise(() => reader.cancel()) + } +} + +const httpMetadata = (protocol: McpProtocol.ProtocolAdapter) => ({ + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "subscription-http-client", version: "1.0.0" } +}) + +const httpListenRequest = (protocol: McpProtocol.ProtocolAdapter, id: string | number) => ({ + jsonrpc: "2.0", + id, + method: "subscriptions/listen", + params: { + notifications: { toolsListChanged: true }, + _meta: httpMetadata(protocol) + } +}) + +const httpHeaders = (protocol: McpProtocol.ProtocolAdapter): HeadersInit => ({ + "MCP-Protocol-Version": protocol.protocolVersion, + "Mcp-Method": "subscriptions/listen" +}) + +const makeHttpSubscriptionHarness = Effect.fnUntraced(function*(protocol: McpProtocol.ProtocolAdapter) { + const serverReady = yield* Deferred.make() + const registrations = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addTool(makeTool("http-subscription-baseline")) + yield* Deferred.succeed(serverReady, server) + }) + ) + const harness = yield* makeHttpHarness( + registrations.pipe(Layer.provideMerge(makeServerLayer({ + name: "SubscriptionConformance", + protocols: [protocol] + }))) + ) + return { harness, serverReady } +}) + +export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Subscriptions", () => { + // SEP-2575 replaces unsolicited list-change notifications and resources/subscribe with subscriptions/listen. + // https://modelcontextprotocol.io/seps/2575-stateless-mcp + // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions + it.effect("should advertise supported subscription capabilities when features are registered", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + + assert.deepStrictEqual(discovered.message.result.capabilities.tools, { listChanged: true }) + assert.deepStrictEqual(discovered.message.result.capabilities.resources, { + listChanged: true, + subscribe: true + }) + assert.deepStrictEqual(discovered.message.result.capabilities.prompts, { listChanged: true }) + })) + + it.effect("should not advertise subscriptions and should reject listen when the transport cannot send notifications", () => + Effect.gen(function*() { + const outbound = yield* Queue.unbounded() + const disconnects = yield* Queue.unbounded() + const writeRequest = yield* Deferred.make< + (clientId: number, message: RpcMessage.FromClientEncoded) => Effect.Effect + >() + const transport = yield* RpcServer.Protocol.make((write) => + Deferred.succeed(writeRequest, write).pipe( + Effect.as({ + disconnects, + send: (_clientId, message) => Queue.offer(outbound, message).pipe(Effect.asVoid), + end: (_clientId) => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false + }) + ) + ) + yield* Effect.gen(function*() { + yield* Layer.build( + subscriptionRegistrations.pipe( + Layer.provideMerge( + McpServer.layer({ + name: "SubscriptionConformance", + version: "1.0.0", + protocols: [protocol] + }).pipe(Layer.provide(Layer.succeed(RpcServer.Protocol, transport))) + ) + ) + ) + return yield* Effect.never + }).pipe(Effect.scoped, Effect.forkScoped) + const send = yield* Deferred.await(writeRequest) + const metadata = httpMetadata(protocol) + + yield* send(0, { + _tag: "Request", + id: 1, + tag: "server/discover", + payload: { _meta: metadata }, + headers: [] + }) + const discovery = yield* Queue.take(outbound) + if (discovery._tag !== "Exit" || discovery.exit._tag !== "Success") { + return assert.fail("Expected successful discovery response") + } + assert(Predicate.isObject(discovery.exit.value)) + assert(Predicate.isObject(discovery.exit.value.capabilities)) + assert.deepStrictEqual(discovery.exit.value.capabilities.tools, { listChanged: false }) + assert.deepStrictEqual(discovery.exit.value.capabilities.resources, { + listChanged: false, + subscribe: false + }) + assert.deepStrictEqual(discovery.exit.value.capabilities.prompts, { listChanged: false }) + + yield* send(0, { + _tag: "Request", + id: 2, + tag: "subscriptions/listen", + payload: { notifications: { toolsListChanged: true }, _meta: metadata }, + headers: [] + }) + const listenResponse = yield* Queue.take(outbound) + if (listenResponse._tag !== "Exit" || listenResponse.exit._tag !== "Failure") { + return assert.fail("Expected failed subscription response") + } + const failure = listenResponse.exit.cause.find((cause) => cause._tag === "Fail") + assert(failure !== undefined && failure._tag === "Fail") + assert(Predicate.isObject(failure.error)) + assert.strictEqual(failure.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + })) + + it.effect("should acknowledge with the exact identifier when it is numeric or string-valued", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.server.addTool(makeTool("subscription-baseline")) + yield* fixture.initialize() + + for (const id of [42, "subscription-42"] as const) { + const notifications = { toolsListChanged: true } + const request = yield* listen(fixture, id, notifications) + assertAcknowledged(yield* fixture.takeMessage, id, notifications) + yield* request.cancel("test complete") + } + })) + + it.effect("should deliver a change notification when its kind is requested", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.server.addTool(makeTool("subscription-tool-baseline")) + yield* fixture.server.addPrompt(makePrompt("subscription-prompt-baseline")) + yield* fixture.initialize() + const id = "filtered-subscription" + const notifications = { + toolsListChanged: true, + resourcesListChanged: true + } + const request = yield* listen(fixture, id, notifications) + assertAcknowledged(yield* fixture.takeMessage, id, notifications) + + yield* fixture.server.addPrompt(makePrompt("must-not-be-delivered")) + yield* fixture.server.addTool(makeTool("must-be-delivered")) + yield* fixture.flushListChanged + + const toolsChanged = yield* fixture.takeMessage + assert.strictEqual(toolsChanged.method, "notifications/tools/list_changed") + assert.strictEqual(subscriptionIdOf(toolsChanged), id) + + yield* fixture.server.addResource(makeResource("file:///list-change-sentinel")) + yield* fixture.flushListChanged + const resourcesChanged = yield* fixture.takeMessage + assert.strictEqual(resourcesChanged.method, "notifications/resources/list_changed") + assert.strictEqual(subscriptionIdOf(resourcesChanged), id) + + yield* request.cancel() + })) + + it.effect("should deliver a resource update when its URI is subscribed", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.initialize() + const id = "resource-uri-subscription" + const notifications = { resourceSubscriptions: ["file:///subscribed"] } + const request = yield* listen(fixture, id, notifications) + assertAcknowledged(yield* fixture.takeMessage, id, notifications) + + yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///other" }) + yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///subscribed" }) + const resourceUpdated = yield* fixture.takeMessage + assert.strictEqual(resourceUpdated.method, "notifications/resources/updated") + assert.strictEqual(subscriptionIdOf(resourceUpdated), id) + assert.strictEqual(paramsOf(resourceUpdated).uri, "file:///subscribed") + yield* request.cancel() + })) + + it.effect("should acknowledge only the supported subset when requested filters exceed server capabilities", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol, [protocol], toolOnlyRegistration) + yield* fixture.initialize() + const request = yield* listen(fixture, "honored-subset", { + toolsListChanged: true, + promptsListChanged: true + }) + + assertAcknowledged(yield* fixture.takeMessage, request.id, { toolsListChanged: true }) + yield* request.cancel() + + const unsupported = yield* listen(fixture, "unsupported-filters", { + toolsListChanged: false, + promptsListChanged: true + }) + assertAcknowledged(yield* fixture.takeMessage, unsupported.id, {}) + yield* unsupported.cancel() + })) + + it.effect("should deliver a matching event to each subscription", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.initialize() + const first = yield* listen(fixture, "fanout-first", { toolsListChanged: true }) + const second = yield* listen(fixture, "fanout-second", { toolsListChanged: true }) + assertAcknowledged(yield* fixture.takeMessage, first.id, { toolsListChanged: true }) + assertAcknowledged(yield* fixture.takeMessage, second.id, { toolsListChanged: true }) + + yield* fixture.server.addTool(makeTool("fanout-event")) + yield* fixture.flushListChanged + const delivered = [yield* fixture.takeMessage, yield* fixture.takeMessage] + assert.deepStrictEqual( + delivered.map(subscriptionIdOf).sort(), + [first.id, second.id].sort() + ) + yield* first.cancel() + yield* second.cancel() + })) + + it.effect("should keep another subscription active when its peer is cancelled", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.server.addTool(makeTool("concurrent-tool-baseline")) + yield* fixture.initialize() + const tools = yield* listen(fixture, "tools-subscription", { toolsListChanged: true }) + const resources = yield* listen(fixture, "resources-subscription", { + resourceSubscriptions: ["file:///sentinel"] + }) + assertAcknowledged(yield* fixture.takeMessage, tools.id, { toolsListChanged: true }) + assertAcknowledged(yield* fixture.takeMessage, resources.id, { + resourceSubscriptions: ["file:///sentinel"] + }) + + yield* fixture.server.addTool(makeTool("concurrent-tool-event")) + yield* fixture.flushListChanged + const toolsChanged = yield* fixture.takeMessage + assert.strictEqual(toolsChanged.method, "notifications/tools/list_changed") + assert.strictEqual(subscriptionIdOf(toolsChanged), tools.id) + + yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///sentinel" }) + const resourceUpdated = yield* fixture.takeMessage + assert.strictEqual(resourceUpdated.method, "notifications/resources/updated") + assert.strictEqual(subscriptionIdOf(resourceUpdated), resources.id) + + yield* tools.cancel("only tools are no longer needed") + yield* fixture.sendRequest("ping", {}) + yield* fixture.server.addTool(makeTool("cancelled-tools-must-not-receive")) + yield* fixture.flushListChanged + yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///sentinel" }) + + const surviving = yield* fixture.takeMessage + assert.strictEqual(surviving.method, "notifications/resources/updated") + assert.strictEqual(subscriptionIdOf(surviving), resources.id) + yield* resources.cancel() + })) + + it.effect("should preserve notification metadata and own the subscription identifier", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.initialize() + const request = yield* listen(fixture, "authoritative-subscription", { toolsListChanged: true }) + assertAcknowledged(yield* fixture.takeMessage, request.id, { toolsListChanged: true }) + + yield* fixture.server.notifications["notifications/tools/list_changed"]({ + _meta: { + source: "metadata-sentinel", + [subscriptionIdKey]: "untrusted-subscription" + } + }) + const notification = yield* fixture.takeMessage + const params = paramsOf(notification) + const metadata = params._meta + assert.strictEqual(notification.method, "notifications/tools/list_changed") + assert(Predicate.isObject(metadata)) + assert.strictEqual(metadata.source, "metadata-sentinel") + assert.strictEqual(metadata[subscriptionIdKey], request.id) + yield* request.cancel() + })) + + it.effect("should deliver modern changes only when an active subscription matches", () => + Effect.gen(function*() { + const fixture = yield* makeSubscriptionHarness(protocol) + yield* fixture.initialize() + yield* fixture.server.addTool(makeTool("unsolicited-modern-tool")) + yield* fixture.flushListChanged + + const request = yield* listen(fixture, "prompt-sentinel", { promptsListChanged: true }) + assertAcknowledged(yield* fixture.takeMessage, request.id, { promptsListChanged: true }) + yield* fixture.server.addPrompt(makePrompt("prompt-sentinel")) + yield* fixture.flushListChanged + const notification = yield* fixture.takeMessage + assert.strictEqual(notification.method, "notifications/prompts/list_changed") + assert.strictEqual(subscriptionIdOf(notification), request.id) + yield* request.cancel() + })) + + it.effect("should preserve legacy notification delivery when modern and legacy adapters are configured", () => + Effect.gen(function*() { + const legacy = McpProtocol.v2025_06_18 + const fixture = yield* makeSubscriptionHarness(legacy, [protocol, legacy]) + yield* fixture.server.addTool(makeTool("mixed-era-baseline")) + yield* fixture.initialize() + yield* fixture.sendRequest("tools/list", {}) + yield* fixture.server.addTool(makeTool("mixed-era-legacy-event")) + yield* fixture.flushListChanged + + const notification = yield* fixture.awaitOutboundMethod("notifications/tools/list_changed") + assert.notProperty(notification, "id") + assert.notProperty(paramsOf(notification), "_meta") + })) + + it.effect("should stream acknowledgment before matching events when using HTTP", () => + Effect.gen(function*() { + const { harness, serverReady } = yield* makeHttpSubscriptionHarness(protocol) + const id = "reusable-http-subscription" + + const firstResponse = yield* harness.post(httpListenRequest(protocol, id), httpHeaders(protocol)) + const server = yield* Deferred.await(serverReady) + const first = makeSseReader(firstResponse) + assertAcknowledged(yield* first.take(), id, { toolsListChanged: true }) + yield* server.addTool(makeTool("http-subscription-event")) + const notification = yield* first.take() + assert.strictEqual(notification.method, "notifications/tools/list_changed") + assert.strictEqual(subscriptionIdOf(notification), id) + yield* first.cancel + })) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts b/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts new file mode 100644 index 00000000000..6d0aee5a8d5 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts @@ -0,0 +1,7 @@ +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import { layer as makeMcpConformanceLayer } from "./McpConformance/McpConformance.ts" +import * as SubscriptionsTest from "./McpConformance/SubscriptionsTest.ts" + +const protocol = McpProtocol.v2026_07_28 + +SubscriptionsTest.suite(protocol, makeMcpConformanceLayer(protocol)) diff --git a/packages/effect/test/unstable/ai/McpServer/TestUtils/McpStdioHarness.ts b/packages/effect/test/unstable/ai/McpServer/TestUtils/McpStdioHarness.ts index 40bf916ecb7..a6e3362d3bf 100644 --- a/packages/effect/test/unstable/ai/McpServer/TestUtils/McpStdioHarness.ts +++ b/packages/effect/test/unstable/ai/McpServer/TestUtils/McpStdioHarness.ts @@ -1,3 +1,4 @@ +import type * as Arr from "effect/Array" import type * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Deferred from "effect/Deferred" @@ -8,6 +9,7 @@ import * as Queue from "effect/Queue" import * as Sink from "effect/Sink" import * as Stdio from "effect/Stdio" import * as Stream from "effect/Stream" +import * as TestClock from "effect/testing/TestClock" import type * as McpProtocol from "effect/unstable/ai/McpProtocol" import type * as McpSchema from "effect/unstable/ai/McpSchema" import * as McpServer from "effect/unstable/ai/McpServer" @@ -21,6 +23,12 @@ export interface JsonRpcMessage { readonly error?: unknown } +export interface McpStdioRequestHandle { + readonly id: string | number + readonly response: Effect.Effect + readonly cancel: (reason?: string) => Effect.Effect +} + export interface McpStdioHarness { readonly server: McpServer.McpServer["Service"] readonly serverFiber: Fiber.Fiber @@ -32,6 +40,11 @@ export interface McpStdioHarness { params?: unknown, id?: string | number ) => Effect.Effect + readonly startRequest: ( + method: string, + params?: unknown, + id?: string | number + ) => Effect.Effect readonly sendNotification: (method: string, params?: unknown) => Effect.Effect readonly initialize: ( capabilities?: typeof McpSchema.ClientCapabilities.Type @@ -41,6 +54,7 @@ export interface McpStdioHarness { readonly takeRawStdout: Effect.Effect readonly takeStderr: Effect.Effect readonly awaitOutboundMethod: (method: string) => Effect.Effect + readonly flushListChanged: Effect.Effect readonly respond: (id: string | number, result: unknown) => Effect.Effect } @@ -55,9 +69,10 @@ const isResponse = ( const requestKey = (id: string | number) => `${typeof id}:${id}` -export const makeMcpStdioHarness = Effect.fnUntraced(function*( +export const makeMcpStdioHarness = Effect.fnUntraced(function*( protocol: McpProtocol.ProtocolAdapter, - protocols: ReadonlyArray = [protocol] + protocols: Arr.NonEmptyReadonlyArray = [protocol], + registrations?: Layer.Layer ) { const stdin = yield* Queue.unbounded() const stdout = yield* Queue.unbounded() @@ -80,15 +95,15 @@ export const makeMcpStdioHarness = Effect.fnUntraced(function*( }) const ready = yield* Deferred.make() const serverFiber = yield* Effect.gen(function*() { + const serverLayer = McpServer.layerStdio({ + name: "McpConformance", + version: "1.0.0", + protocols + }).pipe(Layer.provide(stdioLayer)) const context = yield* Layer.build( - McpServer.layerStdio({ - name: "McpConformance", - version: "1.0.0", - protocols: protocols as [ - McpProtocol.ProtocolAdapter, - ...Array - ] - }).pipe(Layer.provide(stdioLayer)) + registrations === undefined + ? serverLayer + : registrations.pipe(Layer.provideMerge(serverLayer)) ) yield* Deferred.succeed(ready, Context.get(context, McpServer.McpServer)) return yield* Effect.never @@ -144,13 +159,33 @@ export const makeMcpStdioHarness = Effect.fnUntraced(function*( const sendChunk = (chunk: string | Uint8Array) => Queue.offer(stdin, typeof chunk === "string" ? encoder.encode(chunk) : chunk) const sendRaw = (message: unknown) => sendChunk(`${JSON.stringify(message)}\n`) + const requestMetadata = { + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "stdio-client", version: "1.0.0" } + } + const withRequestMetadata = (params: unknown) => + protocol.runtime._tag === "Stateless" + ? { + ...(typeof params === "object" && params !== null ? params : {}), + _meta: { + ...requestMetadata, + ...(typeof params === "object" && params !== null && "_meta" in params && + typeof params._meta === "object" && params._meta !== null + ? params._meta + : {}) + } + } + : params const sendNotification = (method: string, params?: unknown) => sendRaw({ jsonrpc: "2.0", method, - ...(params === undefined ? {} : { params }) + ...(params === undefined && protocol.runtime._tag !== "Stateless" + ? {} + : { params: withRequestMetadata(params) }) }) - const sendRequest = Effect.fnUntraced(function*( + const startRequest = Effect.fnUntraced(function*( method: string, params?: unknown, id: string | number = nextRequestId++ @@ -162,11 +197,29 @@ export const makeMcpStdioHarness = Effect.fnUntraced(function*( jsonrpc: "2.0", id, method, - ...(params === undefined ? {} : { params }) + ...(params === undefined && protocol.runtime._tag !== "Stateless" + ? {} + : { params: withRequestMetadata(params) }) }) - return yield* Queue.take(responseQueue).pipe( - Effect.ensuring(Effect.sync(() => responseQueues.delete(key))) - ) + return { + id, + response: Queue.take(responseQueue).pipe( + Effect.ensuring(Effect.sync(() => responseQueues.delete(key))) + ), + cancel: (reason?: string) => + sendNotification("notifications/cancelled", { + requestId: id, + ...(reason === undefined ? {} : { reason }) + }) + } + }) + const sendRequest = Effect.fnUntraced(function*( + method: string, + params?: unknown, + id?: string | number + ) { + const request = yield* startRequest(method, params, id) + return yield* request.response }) const takeMessage = Effect.suspend(() => { const retained = retainedMessages.shift() @@ -193,8 +246,17 @@ export const makeMcpStdioHarness = Effect.fnUntraced(function*( sendRaw, sendChunk, sendRequest, + startRequest, sendNotification, initialize: Effect.fnUntraced(function*(capabilities = {}) { + if (protocol.runtime._tag === "Stateless") { + return yield* sendRequest("server/discover", { + _meta: { + ...requestMetadata, + "io.modelcontextprotocol/clientCapabilities": capabilities + } + }) + } const response = yield* sendRequest("initialize", { protocolVersion: protocol.protocolVersion, capabilities, @@ -208,6 +270,7 @@ export const makeMcpStdioHarness = Effect.fnUntraced(function*( takeRawStdout: Queue.take(rawStdout), takeStderr: Queue.take(rawStderr), awaitOutboundMethod, + flushListChanged: TestClock.adjust(0), respond: (id, result) => sendRaw({ jsonrpc: "2.0", id, result }) } satisfies McpStdioHarness }) From 67bf850fa378adb9b19aa56dab7c47320fb8f3e7 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 15 Aug 2026 13:22:05 +0200 Subject: [PATCH 6/9] feat: support JSON-valued MCP tool outputs --- .changeset/swift-tools-return.md | 5 + packages/effect/src/unstable/ai/McpSchema.ts | 24 ++++- packages/effect/src/unstable/ai/McpServer.ts | 11 +-- .../ai/internal/mcpProtocol/v2025_06_18.ts | 23 ++--- .../ai/internal/mcpProtocol/v2025_11_25.ts | 21 ++-- .../ai/McpServer/ProtocolAdapters.test.ts | 95 +++++++++++++++++-- .../typetest/unstable/ai/McpServer.tst.ts | 29 ++++++ 7 files changed, 160 insertions(+), 48 deletions(-) create mode 100644 .changeset/swift-tools-return.md diff --git a/.changeset/swift-tools-return.md b/.changeset/swift-tools-return.md new file mode 100644 index 00000000000..8d3d009fd64 --- /dev/null +++ b/.changeset/swift-tools-return.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Allow MCP tools to declare output schemas and structured results for every JSON value in the 2026-07-28 protocol while earlier adapters continue projecting only object-shaped values. diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index 37e79db0e4c..78f5404ec1a 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -1519,7 +1519,7 @@ export class ToolAnnotations extends Schema.Opaque()(Schema.Str })) {} /** - * Object-root JSON Schema used by MCP tool inputs and outputs. + * Object-root JSON Schema used by MCP tool inputs. * * **Details** * @@ -1550,6 +1550,24 @@ export const ToolJsonSchema: Schema.Codec = Schema.StructWithRes [Schema.Record(Schema.String, Schema.Json)] ) +/** + * JSON Schema used by MCP tool outputs. + * + * Unlike tool inputs, tool outputs may use any JSON Schema root type. + * + * @category tools + * @since 4.0.0 + */ +export type ToolOutputJsonSchema = Schema.JsonObject + +/** + * Schema for {@link ToolOutputJsonSchema}. + * + * @category tools + * @since 4.0.0 + */ +export const ToolOutputJsonSchema: Schema.Codec = Schema.Record(Schema.String, Schema.Json) + /** * Schema for the definition of a tool the client can call. * @@ -1577,7 +1595,7 @@ export class Tool extends Schema.Class( /** * An optional JSON Schema object defining the structure of the tool output. */ - outputSchema: optional(ToolJsonSchema), + outputSchema: optional(ToolOutputJsonSchema), /** * Optional additional tool information. */ @@ -1836,7 +1854,7 @@ export class ToolResultContent extends Schema.Class("@effect/ /** * Optional structured result returned by the tool. */ - structuredContent: optional(Schema.Record(Schema.String, Schema.Unknown)), + structuredContent: optional(Schema.Json), /** * Whether tool execution ended in an error. */ diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index e4b93b9a95d..56f34afd2d1 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -1596,10 +1596,9 @@ export const registerToolkit: >( const annotations = tool.annotations const toolMeta = Context.getOrUndefined(annotations, Tool.Meta) const isDeclaredFailure = Schema.is(tool.failureSchema) - const outputJsonSchema = Tool.getJsonSchemaFromSchema(tool.successSchema) - const outputSchema = outputJsonSchema.type === "object" - ? yield* Schema.decodeUnknownEffect(ToolJsonSchema)(outputJsonSchema).pipe(Effect.orDie) - : undefined + const outputSchema = yield* Schema.decodeUnknownEffect(McpSchema.ToolOutputJsonSchema)( + Tool.getJsonSchemaFromSchema(tool.successSchema) + ).pipe(Effect.orDie) const inputSchema = yield* Schema.decodeUnknownEffect(ToolJsonSchema)( Tool.getJsonSchema(tool) ).pipe(Effect.orDie) @@ -1607,7 +1606,7 @@ export const registerToolkit: >( name: tool.name, description: Tool.getDescription(tool), inputSchema, - ...(outputSchema === undefined ? {} : { outputSchema }), + outputSchema, annotations: { ...(Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), @@ -1634,7 +1633,7 @@ export const registerToolkit: >( Effect.map((result) => new CallToolResult({ isError: false, - structuredContent: typeof result.encodedResult === "object" ? result.encodedResult : undefined, + structuredContent: result.encodedResult, content: result.encodedResult === undefined ? [] : [{ type: "text", text: JSON.stringify(result.encodedResult) diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts index b5dc8d8d837..0b8af38ed68 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -104,20 +104,9 @@ const projectResourceContents = ( blob: Encoding.encodeBase64(content.blob) } -const projectStructuredContent: ( +const projectStructuredContent = ( content: Schema.Json | undefined -) => Effect.Effect< - Schema.JsonObject | undefined, - McpCore.UnsupportedByProtocol -> = Effect.fnUntraced(function*(content) { - if (content === undefined || isJsonObject(content)) { - return content - } - return yield* new McpCore.UnsupportedByProtocol({ - protocolVersion: McpSchema.protocolVersion, - feature: "non-object structured tool content" - }) -}) +): Schema.JsonObject | undefined => content === undefined || isJsonObject(content) ? content : undefined const isJsonObject = (value: Schema.Json): value is Schema.JsonObject => typeof value === "object" && value !== null && !Array.isArray(value) @@ -254,7 +243,9 @@ export const protocol = McpProtocol.make({ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, + outputSchema: Schema.is(McpSchema.Tool.fields.outputSchema)(tool.outputSchema) + ? tool.outputSchema + : undefined, annotations: tool.annotations === undefined ? undefined : McpSchema.ToolAnnotations.make({ @@ -280,9 +271,7 @@ export const protocol = McpProtocol.make({ const content = yield* Effect.forEach(result.content, projectContent).pipe( Effect.mapError(McpProtocol.ProtocolError.fromTool) ) - const structuredContent = yield* projectStructuredContent(result.structuredContent).pipe( - Effect.mapError(McpProtocol.ProtocolError.fromTool) - ) + const structuredContent = projectStructuredContent(result.structuredContent) return McpSchema.CallToolResult.make({ content, structuredContent, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts index 92bdde48e95..9593def537b 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts @@ -104,15 +104,10 @@ const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSche ) }) -const projectStructuredContent = Effect.fnUntraced(function*(content: Schema.Json | undefined) { - if (content === undefined || Schema.is(Schema.Record(Schema.String, Schema.Json))(content)) { - return content - } - return yield* new McpCore.UnsupportedByProtocol({ - protocolVersion: McpSchema.protocolVersion, - feature: "non-object structured tool content" - }) -}) +const projectStructuredContent = ( + content: Schema.Json | undefined +): Schema.JsonObject | undefined => + content === undefined || Schema.is(Schema.Record(Schema.String, Schema.Json))(content) ? content : undefined /** @internal */ export const protocol = McpProtocol.make({ @@ -279,7 +274,9 @@ export const protocol = McpProtocol.make({ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, + outputSchema: Schema.is(McpSchema.Tool.fields.outputSchema)(tool.outputSchema) + ? tool.outputSchema + : undefined, icons: tool.icons, annotations: tool.annotations === undefined ? undefined @@ -314,9 +311,7 @@ export const protocol = McpProtocol.make({ const content = yield* Effect.forEach(result.content, projectContent).pipe( Effect.mapError(McpProtocol.ProtocolError.fromTool) ) - const structuredContent = yield* projectStructuredContent(result.structuredContent).pipe( - Effect.mapError(McpProtocol.ProtocolError.fromTool) - ) + const structuredContent = projectStructuredContent(result.structuredContent) return McpSchema.CallToolResult.make({ content, structuredContent, diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts index 8484c3f09f3..277e5fe5714 100644 --- a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -28,6 +28,11 @@ const SharedTool = Tool.make("shared", { success: Schema.String }).annotate(Tool.Title, "Shared tool title") +const JsonArrayTool = Tool.make("json-array", { + parameters: Tool.EmptyParams, + success: Schema.Tuple([Schema.String, Schema.Null]) +}) + const StructuredOnlyTool = Tool.make("structured-only", { parameters: Tool.EmptyParams, success: Schema.Struct({ value: Schema.String }) @@ -73,6 +78,7 @@ const CapabilityGatedTool = Tool.make("capability-gated", { const TestToolkit = Toolkit.make( SharedTool, + JsonArrayTool, StructuredOnlyTool, ValidatedTool, CapabilityTool, @@ -150,6 +156,7 @@ const makeFixture = Effect.fnUntraced(function*() { state.sharedInvocations++ return "shared-result" }), + "json-array": () => Effect.succeed(["array", null] as const), "structured-only": () => Effect.sync(() => { state.structuredInvocations++ @@ -1240,6 +1247,65 @@ describe("McpServer protocol adapters", () => { assert.notProperty(oldSchemaOutput, "_meta") })) + it.effect("should project non-object JSON Toolkit outputs only for the July protocol", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + + for (const protocolVersion of ["2025-06-18", "2025-11-25"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const tools = listedTools(yield* client.request("tools/list")) + for (const name of ["shared", "json-array"]) { + const tool = tools.find((tool) => tool.name === name) + assert.isDefined(tool) + assert.notProperty(tool, "outputSchema") + } + + for (const name of ["shared", "json-array"]) { + const result = resultOf(yield* client.request("tools/call", { name })) + assert.notProperty(result, "structuredContent") + } + } + + const listResponse = yield* fixture.post( + modernRequest(42, "tools/list"), + { ...modernHeaders("tools/list"), "Mcp-Name": "toolkit-output" } + ) + const listResult = listedTools( + yield* Effect.promise(() => listResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + ) + assert.deepStrictEqual(listResult.find((tool) => tool.name === "shared")?.outputSchema, { type: "string" }) + assert.deepStrictEqual(listResult.find((tool) => tool.name === "json-array")?.outputSchema, { + type: "array", + prefixItems: [{ type: "string" }, { type: "null" }], + minItems: 2, + maxItems: 2 + }) + + const callResponse = yield* fixture.post( + modernRequest(43, "tools/call", { name: "shared", arguments: {} }), + { ...modernHeaders("tools/call"), "Mcp-Name": "shared" } + ) + const callResult = resultOf( + yield* Effect.promise(() => callResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + ) + assert.strictEqual(callResult.structuredContent, "shared-result") + + const arrayCallResponse = yield* fixture.post( + modernRequest(44, "tools/call", { name: "json-array", arguments: {} }), + { ...modernHeaders("tools/call"), "Mcp-Name": "json-array" } + ) + const arrayCallResult = resultOf( + yield* Effect.promise(() => arrayCallResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + ) + assert.deepStrictEqual(arrayCallResult.structuredContent, ["array", null]) + })) + it.effect("should encode binary content for every revision that can represent it", () => Effect.gen(function*() { const fixture = yield* makeLowLevelFixture() @@ -1291,17 +1357,28 @@ describe("McpServer protocol adapters", () => { assert.notProperty(scalarResult, "structuredContent") } - const currentClient = yield* initialize(fixture.post, "2025-06-18") - const objectResult = resultOf( - yield* currentClient.request("tools/call", { name: "structured-object" }) - ) - assert.deepStrictEqual(objectResult.structuredContent, { value: "fixture" }) + for (const protocolVersion of ["2025-06-18", "2025-11-25"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const objectResult = resultOf( + yield* client.request("tools/call", { name: "structured-object" }) + ) + const scalarResult = resultOf( + yield* client.request("tools/call", { name: "structured-scalar" }) + ) + assert.deepStrictEqual(objectResult.structuredContent, { value: "fixture" }) + assert.notProperty(scalarResult, "structuredContent") + } - const scalarError = errorOf( - yield* currentClient.request("tools/call", { name: "structured-scalar" }) + const modernResponse = yield* fixture.post( + modernRequest(41, "tools/call", { name: "structured-scalar", arguments: {} }), + { ...modernHeaders("tools/call"), "Mcp-Name": "structured-scalar" } + ) + const modernResult = resultOf( + yield* Effect.promise(() => modernResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) ) - assert.strictEqual(scalarError.code, McpSchema.INVALID_PARAMS_ERROR_CODE) - assert.match(scalarError.message, /non-object structured tool content is not supported by MCP 2025-06-18/) + assert.strictEqual(modernResult.structuredContent, "fixture") })) it.effect("should preserve only supported metadata when projecting embedded resource content", () => diff --git a/packages/effect/typetest/unstable/ai/McpServer.tst.ts b/packages/effect/typetest/unstable/ai/McpServer.tst.ts index 5846d3feefa..8cc1a3f5750 100644 --- a/packages/effect/typetest/unstable/ai/McpServer.tst.ts +++ b/packages/effect/typetest/unstable/ai/McpServer.tst.ts @@ -16,6 +16,35 @@ const serverOptions = { } as const describe("McpServer", () => { + it("should expose protocol-neutral request facts without a session service", () => { + expect(McpSchema.McpRequestContext.useSync((context) => context.protocolVersion)).type.toBe< + Effect.Effect + >() + expect(McpSchema.McpRequestContext.useSync((context) => context.clientInfo)).type.toBe< + Effect.Effect + >() + expect(McpServer.clientCapabilities).type.toBe< + Effect.Effect + >() + }) + + it("should accept scalar tool output schemas while keeping inputs object-rooted", () => { + expect(McpSchema.Tool.make).type.toBeCallableWith({ + name: "scalar-output", + inputSchema: { type: "object" }, + outputSchema: { type: "string" } + }) + expect({ type: "string" } as const).type.not.toBeAssignableTo() + }) + + it("should accept JSON-valued structured sampling results", () => { + expect(McpSchema.ToolResultContent.make).type.toBeCallableWith({ + toolUseId: "call-1", + content: [], + structuredContent: ["sunny", null, 24] + }) + }) + describe("protocol configuration", () => { it("should accept a non-empty protocol declaration when constructing any server", () => { expect(McpServer.run).type.toBeCallableWith(serverOptions) From b293469b8dbaaff9cc79b144fcfa4d6cd582f7ac Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 15 Aug 2026 13:22:25 +0200 Subject: [PATCH 7/9] test: organize MCP conformance suites by protocol behavior --- .../McpConformance/CompletionTest.ts | 46 +- .../McpConformance/ElicitationTest.ts | 7 +- .../McpConformance/McpConformance.ts | 123 ++++- .../McpConformance/McpConformanceFixtures.ts | 31 +- .../McpServer/McpConformance/PromptsTest.ts | 255 +++++----- .../McpServer/McpConformance/ResourcesTest.ts | 279 +++++------ .../ai/McpServer/McpConformance/ToolsTest.ts | 461 +++++++++++------- .../ai/McpServer/McpMultiRoundTrip.test.ts | 7 - .../ai/McpServer/McpSubscriptions.test.ts | 7 - .../unstable/ai/McpServer/v2024_11_05.test.ts | 5 +- .../unstable/ai/McpServer/v2025_03_26.test.ts | 3 + .../unstable/ai/McpServer/v2025_06_18.test.ts | 3 + .../unstable/ai/McpServer/v2025_11_25.test.ts | 5 + .../unstable/ai/McpServer/v2026_07_28.test.ts | 230 +++++++++ 14 files changed, 939 insertions(+), 523 deletions(-) delete mode 100644 packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts delete mode 100644 packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts create mode 100644 packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts index 9218120ca47..527c6d8540c 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts @@ -47,19 +47,15 @@ const completeRaw = ( export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Completion", () => { - // Shared by the 2025-03-26 and 2025-06-18 specifications, except - // completion context, which was added in 2025-06-18. + // https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/completion describe("Capabilities", () => { - it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( - "MUST advertise completions when argument completion is supported", - () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) + it.effect("MUST advertise completions when argument completion is supported", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) - assert.property(initialized.message.result.capabilities, "completions") - }) - ) + assert.property(initialized.message.result.capabilities, "completions") + })) }) describe("Requesting Completions", () => { @@ -82,19 +78,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.deepStrictEqual(result.completion.values, ["alpha", "beta"]) })) - it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( - "MUST pass previously resolved argument context to the completion handler", - () => - Effect.gen(function*() { - const result = yield* complete( - { type: "ref/prompt", name: "ContextCompletionPrompt" }, - { name: "value", value: "c" }, - { arguments: { locale: "en" } } - ) - - assert.deepStrictEqual(result.completion.values, ["context received"]) - }) - ) it.effect("SHOULD reject an unknown prompt reference with Invalid Params", () => Effect.gen(function*() { const test = yield* McpConformance @@ -165,6 +148,21 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual(result.completion.total, 101) assert.strictEqual(result.completion.hasMore, true) })) + + // https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion#completioncontext + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "should pass previously resolved arguments when completion context is supplied", + () => + Effect.gen(function*() { + const result = yield* complete( + { type: "ref/prompt", name: "ContextCompletionPrompt" }, + { name: "value", value: "c" }, + { arguments: { locale: "en" } } + ) + + assert.deepStrictEqual(result.completion.values, ["context received"]) + }) + ) }) }) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts index 6c357808532..6f5fe8eabe7 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts @@ -40,7 +40,7 @@ const request = { const runElicitation = , unknown>>( client: McpTestPeer["reverseClient"], - protocolVersion: McpProtocol.ProtocolVersion, + protocolVersion: McpProtocol.StatefulProtocolVersion, schema: S ) => McpServer.elicit({ @@ -70,7 +70,10 @@ const runElicitation = +export const suite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Elicitation", () => { // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts index 098b4e5455e..cffbf4eb6fb 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts @@ -36,11 +36,76 @@ const BatchResponse = Schema.Array(Schema.Struct({ result: Schema.Struct({}) })) +const StatelessDiscoverResponse = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Number, + result: Schema.Struct({ capabilities: McpSchema.ServerCapabilities }) +}) + const decodeInitializeResponse = Schema.decodeUnknownEffect(InitializeResponse) const decodeErrorResponse = Schema.decodeUnknownEffect(ErrorResponse) const decodeResultResponse = Schema.decodeUnknownEffect(ResultResponse) const decodeBatchResponse = Schema.decodeUnknownEffect(BatchResponse) +const requestMetadata = (protocol: McpProtocol.ProtocolAdapter) => ({ + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "McpConformanceClient", version: "1.0.0" } +}) + +const statelessBody = (protocol: McpProtocol.ProtocolAdapter, body: unknown): unknown => { + if ( + protocol.runtime._tag !== "Stateless" || + typeof body !== "object" || + body === null || + !("method" in body) + ) { + return body + } + const message = body as { readonly params?: unknown } + const params = typeof message.params === "object" && message.params !== null + ? message.params as Record + : {} + const metadata = typeof params._meta === "object" && params._meta !== null && !Array.isArray(params._meta) + ? params._meta as Record + : {} + return { + ...body, + params: { + ...params, + _meta: { + ...metadata, + ...requestMetadata(protocol) + } + } + } +} + +const statelessHeaders = (protocol: McpProtocol.ProtocolAdapter, body: unknown): HeadersInit => { + if ( + protocol.runtime._tag !== "Stateless" || + typeof body !== "object" || + body === null || + !("method" in body) || + typeof body.method !== "string" + ) { + return {} + } + const params = "params" in body && typeof body.params === "object" && body.params !== null + ? body.params as Record + : {} + const name = body.method === "resources/read" + ? params.uri + : body.method === "tools/call" || body.method === "prompts/get" + ? params.name + : undefined + return { + "MCP-Protocol-Version": protocol.protocolVersion, + "Mcp-Method": body.method, + ...(typeof name === "string" ? { "Mcp-Name": name } : {}) + } +} + export interface InitializedSession { readonly response: Response readonly message: typeof InitializeResponse.Type @@ -115,6 +180,7 @@ export interface McpConformanceShape { options?: McpTestPeerOptions ) => ReturnType readonly observations: Effect.Effect + readonly requestCount: Effect.Effect readonly resetObservations: Effect.Effect readonly decodeError: (response: Response) => Effect.Effect readonly decodeResult: (response: Response) => Effect.Effect @@ -142,7 +208,18 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => promptInvocations: 0, resourceTemplateInvocations: 0 }) + const requestCount = yield* Ref.make(0) const featuresHarness = yield* makeHttpHarness(makeFeaturesServerLayer(protocol, observations)) + const post = ( + harness: typeof defaultHarness, + body: unknown, + headers?: HeadersInit + ) => Ref.update(requestCount, (count) => count + 1).pipe(Effect.andThen(harness.post(body, headers))) + const postText = ( + harness: typeof defaultHarness, + body: string, + headers?: HeadersInit + ) => Ref.update(requestCount, (count) => count + 1).pipe(Effect.andThen(harness.postText(body, headers))) const initializeRequest: McpConformanceShape["initializeRequest"] = (options) => ({ jsonrpc: "2.0", @@ -173,7 +250,33 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => const initialize: McpConformanceShape["initialize"] = Effect.fnUntraced(function*(options) { const server = options?.server ?? "default" const harness = server === "features" ? featuresHarness : defaultHarness - const response = yield* harness.post(initializeRequest(options)) + if (protocol.runtime._tag === "Stateless") { + const request = statelessBody(protocol, { + jsonrpc: "2.0", + id: options?.id ?? 1, + method: "server/discover", + params: {} + }) + const response = yield* post(harness, request, statelessHeaders(protocol, request)) + const decoded = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(StatelessDiscoverResponse)) + ) + return { + response, + message: { + jsonrpc: "2.0", + id: options?.id ?? 1, + result: { + protocolVersion: protocol.protocolVersion, + capabilities: decoded.result.capabilities, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } + } + }, + sessionId: null, + server + } + } + const response = yield* post(harness, initializeRequest(options)) const body = yield* Effect.promise(() => response.json()) return { response, @@ -196,13 +299,20 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => session.server === "features" ? featuresHarness : defaultHarness const sendText: McpConformanceShape["sendText"] = (session, body, options) => - harnessFor(session).postText(body, sessionHeaders(session, options)) + postText(harnessFor(session), body, sessionHeaders(session, options)) - const send: McpConformanceShape["send"] = (session, body, options) => - harnessFor(session).post(body, sessionHeaders(session, options)) + const send: McpConformanceShape["send"] = (session, body, options) => { + if (protocol.runtime._tag !== "Stateless") { + return post(harnessFor(session), body, sessionHeaders(session, options)) + } + const request = statelessBody(protocol, body) + return post(harnessFor(session), request, statelessHeaders(protocol, request)) + } const notifyInitialized: McpConformanceShape["notifyInitialized"] = (session, options) => - send(session, initializedNotification, options) + protocol.runtime._tag === "Stateless" + ? Effect.succeed(new Response(null, { status: 202 })) + : send(session, initializedNotification, options) const ping: McpConformanceShape["ping"] = (session, options) => send(session, pingRequest(options?.id), options) @@ -215,7 +325,7 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => initializeRequest, initializedNotification, pingRequest, - post: defaultHarness.post, + post: (body, headers) => post(defaultHarness, body, headers), request: (request) => Effect.promise(() => defaultHarness.handler(request)), initialize, send, @@ -224,6 +334,7 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => ping, makePeer: (options) => makeMcpTestPeer(protocol, options), observations: Ref.get(observations), + requestCount: Ref.get(requestCount), resetObservations: Ref.set(observations, { toolInvocations: 0, promptInvocations: 0, diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts index 8b052ce5b40..2c51f8b5427 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformanceFixtures.ts @@ -45,8 +45,14 @@ const LogLevelTool = Tool.make("LogLevelTool", { dependencies: [CurrentLogLevel] }) +const RequestMetadataTool = Tool.make("RequestMetadataTool", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [McpSchema.McpRequestContext] +}) + const makeTestToolkitLayer = (observations: Ref.Ref, protocolVersion: string) => { - const TestToolkit = Toolkit.make(TestTool, makeStructuredTool(protocolVersion), LogLevelTool) + const TestToolkit = Toolkit.make(TestTool, makeStructuredTool(protocolVersion), LogLevelTool, RequestMetadataTool) return McpServer.toolkit(TestToolkit).pipe( Layer.provide(TestToolkit.toLayer({ TestTool: ({ value }) => @@ -55,7 +61,9 @@ const makeTestToolkitLayer = (observations: Ref.Ref, protocolVersi toolInvocations: current.toolInvocations + 1 })).pipe(Effect.as(value)), StructuredTool: () => Effect.succeed({ value: "structured" }), - LogLevelTool: () => CurrentLogLevel + LogLevelTool: () => CurrentLogLevel, + RequestMetadataTool: () => + McpSchema.McpRequestContext.useSync((context) => JSON.stringify(context.requestMetadata)) })) ) } @@ -124,6 +132,25 @@ const makeContentToolsLayer = Layer.effectDiscard( handle: () => Effect.die("private defect details") }) + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "JsonSchema2020Tool", + inputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + $defs: { identifier: { type: "string" } }, + properties: { value: { $ref: "#/$defs/identifier" } }, + allOf: [{ required: ["value"] }], + unevaluatedProperties: false + } + }), + annotations: Context.make( + McpSchema.EnabledWhen, + (client) => client.protocolVersion === "2026-07-28" + ), + handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + }) + yield* add( "AudioTool", new McpSchema.CallToolResult({ diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts index a96ce340672..2b2728c067f 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts @@ -33,7 +33,7 @@ const getPromptWire = (name: string) => export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Prompts", () => { - // Identical requirements in the 2024-11-05, 2025-03-26, and 2025-06-18 specifications. + // Shared capability contract; each dated entrypoint owns its normative specification revision. describe("Capabilities", () => { it.effect("MUST advertise prompts when prompts are registered", () => Effect.gen(function*() { @@ -50,89 +50,90 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.notProperty(initialized.message.result.capabilities, "prompts") })) - - it.effect("MUST advertise listChanged when prompt list change notifications are supported", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - - assert.strictEqual(initialized.message.result.capabilities.prompts?.listChanged, true) - })) }) describe("Listing Prompts", () => { - it.effect("MUST list every prompt visible to the initialized client", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "prompts/list", - params: {} + it.effect( + "MUST list every prompt visible to the initialized client", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "prompts/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodePrompts(message.result)) + ) + + const expected = [ + "AudioPrompt", + "ContextCompletionPrompt", + "EmbeddedResourcePrompt", + "ImagePrompt", + "NoArgumentPrompt", + "TestPrompt" + ].sort() + assert.deepStrictEqual(result.prompts.map((prompt) => prompt.name).sort(), expected) }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodePrompts(message.result)) - ) + ) - const expected = [ - "AudioPrompt", - "ContextCompletionPrompt", - "EmbeddedResourcePrompt", - "ImagePrompt", - "NoArgumentPrompt", - "TestPrompt" - ].sort() - assert.deepStrictEqual(result.prompts.map((prompt) => prompt.name).sort(), expected) - })) + it.effect( + "SCHEMA preserves prompt names, descriptions, and arguments", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "prompts/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodePrompts(message.result)) + ) - it.effect("SCHEMA preserves prompt names, descriptions, and arguments", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "prompts/list", - params: {} + const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt") + assert.isDefined(prompt) + assert.strictEqual(prompt.description, "A test prompt") + assert.deepStrictEqual(prompt.arguments?.map((argument) => argument.name), [ + "required", + "optional" + ]) }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodePrompts(message.result)) - ) + ) - const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt") - assert.isDefined(prompt) - assert.strictEqual(prompt.description, "A test prompt") - assert.deepStrictEqual(prompt.arguments?.map((argument) => argument.name), [ - "required", - "optional" - ]) - })) + it.effect( + "MUST mark required and optional prompt arguments correctly", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "prompts/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodePrompts(message.result)) + ) - it.effect("MUST mark required and optional prompt arguments correctly", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "prompts/list", - params: {} + const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt") + assert.isDefined(prompt) + assert.deepStrictEqual(prompt.arguments, [ + { name: "required", required: true }, + { name: "optional", required: false } + ]) }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodePrompts(message.result)) - ) - - const prompt = result.prompts.find((prompt) => prompt.name === "TestPrompt") - assert.isDefined(prompt) - assert.deepStrictEqual(prompt.arguments, [ - { name: "required", required: true }, - { name: "optional", required: false } - ]) - })) + ) }) describe("Getting Prompts", () => { @@ -318,21 +319,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }]) })) - it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( - "MUST return audio message content", - () => - Effect.gen(function*() { - const result = yield* getPromptWire("AudioPrompt") - assert.deepStrictEqual(result.result.messages, [{ - role: "user", - content: { - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - } - }]) - }) - ) it.effect("MUST return embedded resource message content", () => Effect.gen(function*() { const result = yield* getPrompt("EmbeddedResourcePrompt") @@ -348,41 +334,68 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }]) })) - }) - describe("List Changed Notification", () => { - it.effect("SHOULD send a prompt list changed notification when the advertised list changes", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - const makePrompt = (name: string) => ({ - prompt: new McpSchema.Prompt({ name }), - annotations: Context.empty(), - completions: {}, - handle: () => - Effect.succeed( - new McpSchema.GetPromptResult({ - messages: [{ role: "user", content: { type: "text", text: name } }] - }) - ) + it.effect.skipIf(protocol.protocolVersion === "2024-11-05")( + "should return base64 audio content when an audio prompt is requested", + () => + Effect.gen(function*() { + const result = yield* getPromptWire("AudioPrompt") + assert.deepStrictEqual(result.result.messages, [{ + role: "user", + content: { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + } + }]) }) - yield* fixture.server.addPrompt(makePrompt("baseline-list-changed-prompt")) - const initialized = yield* fixture.initialize() - const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) - assert.strictEqual( - initializeResult.capabilities.prompts?.listChanged, - true - ) + ) + }) + }) + }) + +export const statefulLegacySuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + // https://modelcontextprotocol.io/specification/2025-11-25/server/prompts + describe("Prompts > Legacy notifications", () => { + it.effect("should advertise prompt list-change notifications when supported", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) - yield* fixture.server.addPrompt(makePrompt("dynamic-list-changed-prompt")) - const notification = yield* fixture.awaitOutboundMethod("notifications/prompts/list_changed") - assert.strictEqual(notification.jsonrpc, "2.0") - assert.strictEqual(notification.method, "notifications/prompts/list_changed") - assert.notProperty(notification, "id") + assert.strictEqual(initialized.message.result.capabilities.prompts?.listChanged, true) + })) - const response = yield* fixture.sendRequest("prompts/list", {}) - const result = yield* decodePrompts(response.result) - assert.isTrue(result.prompts.some((prompt) => prompt.name === "dynamic-list-changed-prompt")) - })) - }) + it.effect("should send a prompt list-change notification when the advertised list changes", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const makePrompt = (name: string) => ({ + prompt: new McpSchema.Prompt({ name }), + annotations: Context.empty(), + completions: {}, + handle: () => + Effect.succeed( + new McpSchema.GetPromptResult({ + messages: [{ role: "user", content: { type: "text", text: name } }] + }) + ) + }) + yield* fixture.server.addPrompt(makePrompt("baseline-list-changed-prompt")) + yield* fixture.initialize() + yield* fixture.server.addPrompt(makePrompt("dynamic-list-changed-prompt")) + yield* fixture.flushListChanged + + const notification = yield* fixture.awaitOutboundMethod("notifications/prompts/list_changed") + assert.strictEqual(notification.jsonrpc, "2.0") + assert.strictEqual(notification.method, "notifications/prompts/list_changed") + assert.notProperty(notification, "id") + + const response = yield* fixture.sendRequest("prompts/list", {}) + const result = yield* decodePrompts(response.result) + assert.isTrue(result.prompts.some((prompt) => prompt.name === "dynamic-list-changed-prompt")) + })) }) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ResourcesTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ResourcesTest.ts index bdbf71c03d0..c7dd83f2553 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ResourcesTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ResourcesTest.ts @@ -24,7 +24,7 @@ const makeResource = (uri: string, name: string) => ({ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Resources", () => { - // Identical requirements in the 2024-11-05, 2025-03-26, and 2025-06-18 specifications. + // Shared capability contract; each dated entrypoint owns its normative specification revision. describe("Capabilities", () => { it.effect("MUST advertise resources when resources are registered", () => Effect.gen(function*() { @@ -41,22 +41,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.notProperty(initialized.message.result.capabilities, "resources") })) - - it.effect("MUST NOT advertise resource subscriptions when they are unsupported", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - - assert.strictEqual(initialized.message.result.capabilities.resources?.subscribe, false) - })) - - it.effect("MUST advertise listChanged when resource list change notifications are supported", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - - assert.strictEqual(initialized.message.result.capabilities.resources?.listChanged, true) - })) }) describe("Listing Resources", () => { @@ -196,7 +180,8 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman "file:///multiple#second" ]) })) - it.effect("SHOULD return resource not found for an unknown resource URI", () => + + it.effect("should return the revision-specific error when the resource URI is unknown", () => Effect.gen(function*() { const test = yield* McpConformance const initialized = yield* test.initialize({ server: "features" }) @@ -209,7 +194,10 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) const error = yield* test.decodeError(response) - assert.strictEqual(error.error.code, -32002) + assert.strictEqual( + error.error.code, + protocol.protocolVersion === "2026-07-28" ? -32602 : -32002 + ) })) }) @@ -286,154 +274,113 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual((yield* test.observations).resourceTemplateInvocations, 0) })) }) + }) + }) - describe("List Changed Notification", () => { - it.effect("SHOULD send a resource list changed notification when the advertised list changes", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource( - makeResource("file:///baseline-list-changed", "baseline-list-changed-resource") - ) - const initialized = yield* fixture.initialize() - const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) - assert.strictEqual( - initializeResult.capabilities.resources?.listChanged, - true - ) - - yield* fixture.server.addResource( - makeResource("file:///dynamic-list-changed", "dynamic-list-changed-resource") - ) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/list_changed") - assert.strictEqual(notification.jsonrpc, "2.0") - assert.strictEqual(notification.method, "notifications/resources/list_changed") - assert.notProperty(notification, "id") - - const response = yield* fixture.sendRequest("resources/list", {}) - const result = yield* decodeResources(response.result) - assert.isTrue(result.resources.some((resource) => resource.uri === "file:///dynamic-list-changed")) - })) - }) - - describe("Subscriptions", () => { - it.effect("MUST subscribe to a resource when subscriptions are advertised", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) - const initialized = yield* fixture.initialize() - const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) - assert.strictEqual(initializeResult.capabilities.resources?.subscribe, true) - - const response = yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-target" - }) - assert.notProperty(response, "error") - assert.deepStrictEqual(response.result, {}) - - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-target" - }) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") - const payload = yield* decodeResourceUpdated(notification.params) - assert.strictEqual(payload.uri, "file:///subscription-target") - })) - - it.effect("MUST send update notifications only for subscribed resources", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) - yield* fixture.initialize() - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-target" - }) - - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///not-subscribed" - }) - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-target" - }) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") - const payload = yield* decodeResourceUpdated(notification.params) - assert.strictEqual(payload.uri, "file:///subscription-target") - })) - - it.effect("MUST include the updated resource URI in each notification", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) - yield* fixture.initialize() - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-target" - }) - - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-target" - }) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") - assert.strictEqual(notification.jsonrpc, "2.0") - assert.strictEqual(notification.method, "notifications/resources/updated") - assert.notProperty(notification, "id") - const payload = yield* decodeResourceUpdated(notification.params) - assert.strictEqual(payload.uri, "file:///subscription-target") - })) - - it.effect("MUST unsubscribe from resource updates", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) - yield* fixture.server.addResource(makeResource("file:///subscription-sentinel", "subscription-sentinel")) - yield* fixture.initialize() - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-target" - }) - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-sentinel" - }) - - const response = yield* fixture.sendRequest("resources/unsubscribe", { - uri: "file:///subscription-target" - }) - assert.notProperty(response, "error") - assert.deepStrictEqual(response.result, {}) - - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-target" - }) - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-sentinel" - }) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") - const payload = yield* decodeResourceUpdated(notification.params) - assert.strictEqual(payload.uri, "file:///subscription-sentinel") - })) - - it.effect("MUST not send updates after a resource is unsubscribed", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) - yield* fixture.server.addResource(makeResource("file:///subscription-sentinel", "subscription-sentinel")) - yield* fixture.initialize() - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-target" - }) - yield* fixture.sendRequest("resources/subscribe", { - uri: "file:///subscription-sentinel" - }) - yield* fixture.sendRequest("resources/unsubscribe", { - uri: "file:///subscription-target" - }) +export const statefulLegacySuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + // https://modelcontextprotocol.io/specification/2025-11-25/server/resources + describe("Resources > Legacy notifications", () => { + it.effect("should advertise resource list-change notifications when supported", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + + assert.strictEqual(initialized.message.result.capabilities.resources?.listChanged, true) + })) + + it.effect("should send a resource list-change notification when the advertised list changes", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.server.addResource( + makeResource("file:///baseline-list-changed", "baseline-list-changed-resource") + ) + yield* fixture.initialize() + yield* fixture.server.addResource( + makeResource("file:///dynamic-list-changed", "dynamic-list-changed-resource") + ) + yield* fixture.flushListChanged + + const notification = yield* fixture.awaitOutboundMethod("notifications/resources/list_changed") + assert.strictEqual(notification.jsonrpc, "2.0") + assert.strictEqual(notification.method, "notifications/resources/list_changed") + assert.notProperty(notification, "id") + + const response = yield* fixture.sendRequest("resources/list", {}) + const result = yield* decodeResources(response.result) + assert.isTrue(result.resources.some((resource) => resource.uri === "file:///dynamic-list-changed")) + })) + }) - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-target" - }) - yield* fixture.server.notifications["notifications/resources/updated"]({ - uri: "file:///subscription-sentinel" - }) - const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") - const payload = yield* decodeResourceUpdated(notification.params) - assert.strictEqual(payload.uri, "file:///subscription-sentinel") - })) - }) + describe("Resources > Legacy subscriptions", () => { + it.effect("should deliver a resource update with its URI when the resource is subscribed", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) + const initialized = yield* fixture.initialize() + const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) + assert.strictEqual(initializeResult.capabilities.resources?.subscribe, true) + + const response = yield* fixture.sendRequest("resources/subscribe", { + uri: "file:///subscription-target" + }) + assert.notProperty(response, "error") + assert.deepStrictEqual(response.result, {}) + + yield* fixture.server.notifications["notifications/resources/updated"]({ + uri: "file:///subscription-target" + }) + const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") + assert.strictEqual(notification.jsonrpc, "2.0") + assert.strictEqual(notification.method, "notifications/resources/updated") + assert.notProperty(notification, "id") + const payload = yield* decodeResourceUpdated(notification.params) + assert.strictEqual(payload.uri, "file:///subscription-target") + })) + + it.effect("should deliver updates only for subscribed resource URIs", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) + yield* fixture.initialize() + yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-target" }) + + yield* fixture.server.notifications["notifications/resources/updated"]({ uri: "file:///not-subscribed" }) + yield* fixture.server.notifications["notifications/resources/updated"]({ + uri: "file:///subscription-target" + }) + const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") + const payload = yield* decodeResourceUpdated(notification.params) + assert.strictEqual(payload.uri, "file:///subscription-target") + })) + + it.effect("should stop delivering updates after a resource is unsubscribed", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.server.addResource(makeResource("file:///subscription-target", "subscription-target")) + yield* fixture.server.addResource(makeResource("file:///subscription-sentinel", "subscription-sentinel")) + yield* fixture.initialize() + yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-target" }) + yield* fixture.sendRequest("resources/subscribe", { uri: "file:///subscription-sentinel" }) + + const response = yield* fixture.sendRequest("resources/unsubscribe", { + uri: "file:///subscription-target" + }) + assert.notProperty(response, "error") + assert.deepStrictEqual(response.result, {}) + + yield* fixture.server.notifications["notifications/resources/updated"]({ + uri: "file:///subscription-target" + }) + yield* fixture.server.notifications["notifications/resources/updated"]({ + uri: "file:///subscription-sentinel" + }) + const notification = yield* fixture.awaitOutboundMethod("notifications/resources/updated") + const payload = yield* decodeResourceUpdated(notification.params) + assert.strictEqual(payload.uri, "file:///subscription-sentinel") + })) }) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts index 9c711f423c4..8df0e2e06c6 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts @@ -9,6 +9,13 @@ import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" const decodeTools = Schema.decodeUnknownEffect(McpSchema.ListToolsResult) const decodeCallTool = Schema.decodeUnknownEffect(McpSchema.CallToolResult) +const decodeJsonSchema2020Tools = Schema.decodeUnknownEffect(Schema.Struct({ + tools: Schema.Array(Schema.Struct({ + name: Schema.String, + inputSchema: Schema.Record(Schema.String, Schema.Json), + outputSchema: Schema.optional(Schema.Record(Schema.String, Schema.Json)) + })) +})) const callTool = (name: string, arguments_: Record = {}) => Effect.gen(function*() { @@ -43,7 +50,7 @@ const callToolWire = (name: string) => export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Tools", () => { - // Identical requirements in the 2024-11-05, 2025-03-26, and 2025-06-18 specifications. + // Shared capability contract; each dated entrypoint owns its normative specification revision. describe("Capabilities", () => { it.effect("MUST advertise the tools capability when tools are registered", () => Effect.gen(function*() { @@ -60,87 +67,34 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.notProperty(initialized.message.result.capabilities, "tools") })) - - it.effect("MUST advertise listChanged when tool list change notifications are supported", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - - assert.strictEqual(initialized.message.result.capabilities.tools?.listChanged, true) - })) }) describe("Listing Tools", () => { - it.effect("MUST list every tool visible to the initialized client", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: {} - }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodeTools(message.result)) - ) - - const expected = [ - "AudioTool", - "DefectTool", - "EmbeddedResourceTool", - "ErrorTool", - "ImageTool", - "LogLevelTool", - "MultipleContentTool", - "ResourceLinkTool", - "StructuredTool", - "TestTool" - ].sort() - assert.deepStrictEqual(result.tools.map((tool) => tool.name).sort(), expected) - })) - - it.effect("SCHEMA preserves tool names and descriptions", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: {} - }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodeTools(message.result)) - ) - - const tool = result.tools.find((tool) => tool.name === "TestTool") - assert.isDefined(tool) - assert.strictEqual(tool.description, "A test tool") - })) + it.effect( + "SCHEMA preserves tool names and descriptions", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeTools(message.result)) + ) - it.effect("MUST return each tool input schema", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/list", - params: {} + const tool = result.tools.find((tool) => tool.name === "TestTool") + assert.isDefined(tool) + assert.strictEqual(tool.description, "A test tool") }) - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodeTools(message.result)) - ) + ) - assert.isTrue(result.tools.every((tool) => tool.inputSchema.type === "object")) - })) - it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( - "MUST return each declared tool output schema", + it.effect( + "MUST return each tool input schema", () => Effect.gen(function*() { const test = yield* McpConformance @@ -156,15 +110,32 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman Effect.flatMap((message) => decodeTools(message.result)) ) - assert.strictEqual( - result.tools.find((tool) => tool.name === "StructuredTool")?.outputSchema?.type, - "object" - ) - const scalarTool = result.tools.find((tool) => tool.name === "TestTool") - assert.isDefined(scalarTool) - assert.notProperty(scalarTool, "outputSchema") + assert.isTrue(result.tools.every((tool) => tool.inputSchema.type === "object")) }) ) + + it.effect("should list shared tools and gate modern-only tools by era", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeTools(message.result)) + ) + const names = new Set(result.tools.map((tool) => tool.name)) + for (const name of ["TestTool", "StructuredTool", "LogLevelTool", "RequestMetadataTool"]) { + assert.isTrue(names.has(name)) + } + for (const name of ["JsonSchema2020Tool", "MrtrTool"]) { + assert.strictEqual(names.has(name), protocol.runtime._tag === "Stateless") + } + })) }) describe("Calling Tools", () => { @@ -211,49 +182,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual((yield* test.observations).toolInvocations, before) })) - it.effect("MUST reject malformed tool params without invoking a handler", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const before = (yield* test.observations).toolInvocations - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { arguments: { value: "called" } } - }) - const error = yield* test.decodeError(response) - - assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) - assert.strictEqual((yield* test.observations).toolInvocations, before) - })) - - it.effect("MUST handle arguments that do not match the input schema for the revision", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize({ server: "features" }) - yield* test.notifyInitialized(initialized) - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: "TestTool", - arguments: { value: 123 } - } - }) - if (protocol.protocolVersion === "2025-11-25") { - const result = yield* test.decodeResult(response).pipe( - Effect.flatMap((message) => decodeCallTool(message.result)) - ) - assert.strictEqual(result.isError, true) - assert.strictEqual(result.content[0]?.type, "text") - } else { - const error = yield* test.decodeError(response) - assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) - } - })) it.effect("MUST not invoke a tool handler when argument validation fails", () => Effect.gen(function*() { const test = yield* McpConformance @@ -301,31 +229,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman mimeType: "image/png" }]) })) - it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( - "SCHEMA returns audio content", - () => - Effect.gen(function*() { - const result = yield* callToolWire("AudioTool") - assert.deepStrictEqual(result.result.content, [{ - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - }]) - }) - ) - it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( - "SCHEMA returns resource links", - () => - Effect.gen(function*() { - const result = yield* callTool("ResourceLinkTool") - assert.deepStrictEqual(result.content, [{ - type: "resource_link", - uri: "file:///test", - name: "TestResource", - mimeType: "text/plain" - }]) - }) - ) it.effect("SCHEMA returns embedded resources", () => Effect.gen(function*() { const result = yield* callTool("EmbeddedResourceTool") @@ -346,14 +249,6 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman { type: "text", text: "second" } ]) })) - it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( - "SCHEMA returns structured content", - () => - Effect.gen(function*() { - const result = yield* callTool("StructuredTool") - assert.deepStrictEqual(result.structuredContent, { value: "structured" }) - }) - ) it.effect("MUST return tool execution failures with isError", () => Effect.gen(function*() { const result = yield* callTool("ErrorTool") @@ -396,38 +291,232 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual(result.error.message, "Internal error") assert.notMatch(JSON.stringify(result), /private defect details/) })) - }) - describe("List Changed Notification", () => { - it.effect("SHOULD send a tool list changed notification when the advertised list changes", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - const makeTool = (name: string) => ({ - tool: new McpSchema.Tool({ - name, - inputSchema: { type: "object", properties: {} } - }), - annotations: Context.empty(), - handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + it.effect.skipIf(protocol.protocolVersion === "2024-11-05")( + "should return base64 audio content when an audio tool is called", + () => + Effect.gen(function*() { + const result = yield* callToolWire("AudioTool") + assert.deepStrictEqual(result.result.content, [{ + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }]) }) - yield* fixture.server.addTool(makeTool("baseline-list-changed-tool")) - const initialized = yield* fixture.initialize() - const initializeResult = yield* Schema.decodeUnknownEffect(McpSchema.InitializeResult)(initialized.result) - assert.strictEqual( - initializeResult.capabilities.tools?.listChanged, - true - ) + ) - yield* fixture.server.addTool(makeTool("dynamic-list-changed-tool")) - const notification = yield* fixture.awaitOutboundMethod("notifications/tools/list_changed") - assert.strictEqual(notification.jsonrpc, "2.0") - assert.strictEqual(notification.method, "notifications/tools/list_changed") - assert.notProperty(notification, "id") + describe.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "Structured content", + () => { + it.effect("should advertise an object output schema when a tool declares one", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeTools(message.result)) + ) + + assert.strictEqual( + result.tools.find((tool) => tool.name === "StructuredTool")?.outputSchema?.type, + "object" + ) + })) + + it.effect("should return a resource link when a resource-link tool is called", () => + Effect.gen(function*() { + const result = yield* callTool("ResourceLinkTool") + assert.deepStrictEqual(result.content, [{ + type: "resource_link", + uri: "file:///test", + name: "TestResource", + mimeType: "text/plain" + }]) + })) + + it.effect("should return structured content when a structured tool is called", () => + Effect.gen(function*() { + const result = yield* callTool("StructuredTool") + assert.deepStrictEqual(result.structuredContent, { value: "structured" }) + })) + } + ) - const response = yield* fixture.sendRequest("tools/list", {}) - const result = yield* decodeTools(response.result) - assert.isTrue(result.tools.some((tool) => tool.name === "dynamic-list-changed-tool")) + it.effect("should report invalid tool arguments according to the selected revision", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* invalidArgumentsResponse() + if (["2024-11-05", "2025-03-26", "2025-06-18"].includes(protocol.protocolVersion)) { + const error = yield* test.decodeError(response) + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + return + } + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeCallTool(message.result)) + ) + assert.strictEqual(result.isError, true) + assert.strictEqual(result.content[0]?.type, "text") })) }) }) }) + +export const statelessModernSuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Tools > JSON Schema 2020-12", () => { + // SEP-2106: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12 + // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool + it.effect("should preserve 2020-12 keywords when listing an input schema", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeJsonSchema2020Tools(message.result)) + ) + const tool = result.tools.find((tool) => tool.name === "JsonSchema2020Tool") + + assert.isDefined(tool) + assert.deepStrictEqual(tool.inputSchema, { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + $defs: { identifier: { type: "string" } }, + properties: { value: { $ref: "#/$defs/identifier" } }, + allOf: [{ required: ["value"] }], + unevaluatedProperties: false + }) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#output-schema + it.effect("should list a non-object output schema when the tool declares one", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeJsonSchema2020Tools(message.result)) + ) + const tool = result.tools.find((tool) => tool.name === "TestTool") + + assert.isDefined(tool) + assert.deepStrictEqual(tool.outputSchema, { type: "string" }) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content + it.effect("should return primitive structured content when declared by the tool", () => + Effect.gen(function*() { + const result = yield* callTool("TestTool", { value: "called" }) + + assert.strictEqual(result.structuredContent, "called") + })) + }) + + describe("Tools > Modern request headers", () => { + // SEP-2243: https://modelcontextprotocol.io/seps/2243-http-standardization + // The final 2026-07-28 specification assigns HeaderMismatch error code -32020. + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#request-metadata + it.effect("should reject a tools/call request when its required routing name is missing", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const before = (yield* test.observations).toolInvocations + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { arguments: { value: "called" } } + }) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.error.code, -32020) + assert.strictEqual((yield* test.observations).toolInvocations, before) + })) + }) + }) + +const invalidArgumentsResponse = Effect.fnUntraced(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + return yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "TestTool", arguments: { value: 123 } } + }) +}) + +export const statefulLegacySuite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Tools > Legacy validation", () => { + it.effect("should reject malformed tool parameters without invoking a handler", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + yield* test.notifyInitialized(initialized) + const before = (yield* test.observations).toolInvocations + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { arguments: { value: "called" } } + }) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.strictEqual((yield* test.observations).toolInvocations, before) + })) + }) + + // https://modelcontextprotocol.io/specification/2025-11-25/server/tools + describe("Tools > Legacy notifications", () => { + it.effect("should advertise tool list-change notifications when supported", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + assert.strictEqual(discovered.message.result.capabilities.tools?.listChanged, true) + })) + + it.effect("should send a tool list-change notification when the advertised list changes", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const makeTool = (name: string) => ({ + tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }), + annotations: Context.empty(), + handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + }) + yield* fixture.server.addTool(makeTool("baseline-list-changed-tool")) + yield* fixture.initialize() + yield* fixture.server.addTool(makeTool("dynamic-list-changed-tool")) + yield* fixture.flushListChanged + + const notification = yield* fixture.awaitOutboundMethod("notifications/tools/list_changed") + assert.strictEqual(notification.jsonrpc, "2.0") + assert.strictEqual(notification.method, "notifications/tools/list_changed") + assert.notProperty(notification, "id") + + const response = yield* fixture.sendRequest("tools/list", {}) + const result = yield* decodeTools(response.result) + assert.isTrue(result.tools.some((tool) => tool.name === "dynamic-list-changed-tool")) + })) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts b/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts deleted file mode 100644 index f356aecded6..00000000000 --- a/packages/effect/test/unstable/ai/McpServer/McpMultiRoundTrip.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as McpProtocol from "effect/unstable/ai/McpProtocol" -import { layer as makeMcpConformanceLayer } from "./McpConformance/McpConformance.ts" -import * as MultiRoundTripTest from "./McpConformance/MultiRoundTripTest.ts" - -const protocol = McpProtocol.v2026_07_28 - -MultiRoundTripTest.suite(protocol, makeMcpConformanceLayer(protocol)) diff --git a/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts b/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts deleted file mode 100644 index 6d0aee5a8d5..00000000000 --- a/packages/effect/test/unstable/ai/McpServer/McpSubscriptions.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as McpProtocol from "effect/unstable/ai/McpProtocol" -import { layer as makeMcpConformanceLayer } from "./McpConformance/McpConformance.ts" -import * as SubscriptionsTest from "./McpConformance/SubscriptionsTest.ts" - -const protocol = McpProtocol.v2026_07_28 - -SubscriptionsTest.suite(protocol, makeMcpConformanceLayer(protocol)) diff --git a/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts b/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts index 288e61c5176..7f458d7e543 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest" import * as Effect from "effect/Effect" import * as McpProtocol from "effect/unstable/ai/McpProtocol" import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" -import * as CompletionTest from "./McpConformance/CompletionTest.ts" import * as LifecycleTest from "./McpConformance/LifecycleTest.ts" import * as LoggingTest from "./McpConformance/LoggingTest.ts" import * as McpConformance from "./McpConformance/McpConformance.ts" @@ -22,10 +21,12 @@ BaseProtocolTest.suite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) -CompletionTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) +ToolsTest.statefulLegacySuite(protocol, testLayer) ResourcesTest.suite(protocol, testLayer) +ResourcesTest.statefulLegacySuite(protocol, testLayer) PromptsTest.suite(protocol, testLayer) +PromptsTest.statefulLegacySuite(protocol, testLayer) RootsTest.suite(protocol, testLayer) SamplingTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts index d30326ff7f9..4057d1b83bd 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts @@ -26,8 +26,11 @@ UtilitiesTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) CompletionTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) +ToolsTest.statefulLegacySuite(protocol, testLayer) ResourcesTest.suite(protocol, testLayer) +ResourcesTest.statefulLegacySuite(protocol, testLayer) PromptsTest.suite(protocol, testLayer) +PromptsTest.statefulLegacySuite(protocol, testLayer) RootsTest.suite(protocol, testLayer) SamplingTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts index 93fd7adc09c..b8d14587127 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts @@ -21,8 +21,11 @@ BaseProtocolTest.suite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) +ToolsTest.statefulLegacySuite(protocol, testLayer) ResourcesTest.suite(protocol, testLayer) +ResourcesTest.statefulLegacySuite(protocol, testLayer) PromptsTest.suite(protocol, testLayer) +PromptsTest.statefulLegacySuite(protocol, testLayer) CompletionTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) RootsTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts index 17ea38eda5f..5b675273ec3 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts @@ -10,6 +10,7 @@ import * as LoggingTest from "./McpConformance/LoggingTest.ts" import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" import * as PromptsTest from "./McpConformance/PromptsTest.ts" import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" +import * as RootsTest from "./McpConformance/RootsTest.ts" import * as SamplingTest from "./McpConformance/SamplingTest.ts" import * as ToolsTest from "./McpConformance/ToolsTest.ts" import * as TransportsTest from "./McpConformance/TransportsTest.ts" @@ -23,10 +24,14 @@ BaseProtocolTest.suite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) +ToolsTest.statefulLegacySuite(protocol, testLayer) ResourcesTest.suite(protocol, testLayer) +ResourcesTest.statefulLegacySuite(protocol, testLayer) PromptsTest.suite(protocol, testLayer) +PromptsTest.statefulLegacySuite(protocol, testLayer) CompletionTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) +RootsTest.suite(protocol, testLayer) SamplingTest.suite(protocol, testLayer) ElicitationTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts b/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts new file mode 100644 index 00000000000..91093760762 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts @@ -0,0 +1,230 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Encoding from "effect/Encoding" +import * as Schema from "effect/Schema" +import * as McpProtocol from "effect/unstable/ai/McpProtocol" +import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as CompletionTest from "./McpConformance/CompletionTest.ts" +import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" +import * as MultiRoundTripTest from "./McpConformance/MultiRoundTripTest.ts" +import * as PromptsTest from "./McpConformance/PromptsTest.ts" +import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" +import * as SubscriptionsTest from "./McpConformance/SubscriptionsTest.ts" +import * as ToolsTest from "./McpConformance/ToolsTest.ts" +import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" + +const protocol = McpProtocol.v2026_07_28 +const testLayer = makeMcpConformanceLayer(protocol) + +const metadata = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "McpConformanceClient", version: "1.0.0" } +} as const + +const request = ( + id: string | number, + method: string, + params: Record = {} +) => ({ + jsonrpc: "2.0", + id, + method, + params: { ...params, _meta: metadata } +}) + +const headers = (method: string, name?: string): HeadersInit => ({ + "MCP-Protocol-Version": protocol.protocolVersion, + "Mcp-Method": method, + ...(name === undefined ? {} : { "Mcp-Name": name }) +}) + +const decodeError = (response: Response) => + Effect.promise(() => response.json()).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ + id: Schema.NullOr(Schema.Union([Schema.String, Schema.Number])), + error: McpSchema.McpError + }))) + ) + +ToolsTest.suite(protocol, testLayer) +ToolsTest.statelessModernSuite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +MultiRoundTripTest.suite(protocol, testLayer) +SubscriptionsTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Lifecycle", () => { + // SEP-2575: https://modelcontextprotocol.io/seps/2575-stateless-mcp + // https://modelcontextprotocol.io/specification/2026-07-28/server/discovery + it.effect("should discover the server when no initialization or session exists", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + + assert.strictEqual(discovered.response.status, 200) + assert.isNull(discovered.sessionId) + assert.strictEqual(discovered.message.result.protocolVersion, protocol.protocolVersion) + })) + + it.effect("should serve independent requests when no discovery or session exists", () => + Effect.gen(function*() { + const test = yield* McpConformance + const first = yield* test.post(request(1, "tools/list"), headers("tools/list")) + const second = yield* test.post(request(2, "tools/list"), { + ...headers("tools/list"), + "Mcp-Session-Id": "ignored-modern-session" + }) + + assert.strictEqual(first.status, 200) + assert.strictEqual(second.status, 200) + assert.isNull(first.headers.get("Mcp-Session-Id")) + })) + }) + + describe("Transports", () => { + // SEP-2567: https://modelcontextprotocol.io/seps/2567-remove-sessions + it.effect("should exchange self-contained newline-delimited requests over stdio", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const response = yield* fixture.sendRequest("server/discover", {}) + const result = Schema.decodeUnknownSync(Schema.Struct({ + supportedVersions: Schema.Array(Schema.String), + capabilities: Schema.Record(Schema.String, Schema.Unknown), + resultType: Schema.Literal("complete"), + ttlMs: Schema.Number, + cacheScope: Schema.Literal("private"), + _meta: Schema.Struct({ + "io.modelcontextprotocol/serverInfo": Schema.Struct({ + name: Schema.String, + version: Schema.String + }) + }) + }))(response.result) + + assert.deepStrictEqual(result.supportedVersions, ["2026-07-28"]) + assert.deepStrictEqual(result.capabilities, { completions: {}, logging: {} }) + assert.strictEqual(result.ttlMs, 0) + assert.deepStrictEqual(result._meta["io.modelcontextprotocol/serverInfo"], { + name: "McpConformance", + version: "1.0.0" + }) + })) + + it.effect("should accept every required routing name when the header matches the request", () => + Effect.gen(function*() { + const test = yield* McpConformance + const cases = [ + ["tools/call", { name: "ConformanceTool", arguments: {} }, "ConformanceTool"], + ["resources/read", { uri: "file:///conformance.txt" }, "file:///conformance.txt"], + ["prompts/get", { name: "ConformancePrompt", arguments: {} }, "ConformancePrompt"] + ] as const + + for (const [index, [method, params, name]] of cases.entries()) { + const response = yield* test.post(request(index + 1, method, params), headers(method, name)) + assert.strictEqual(response.status, 200) + } + + const encodedName = `=?base64?${Encoding.encodeBase64("file:///conformance.txt")}?=` + const encoded = yield* test.post( + request(4, "resources/read", { uri: "file:///conformance.txt" }), + headers("resources/read", encodedName) + ) + assert.strictEqual(encoded.status, 200) + })) + + it.effect("should reject routing headers when they are missing, malformed, or mismatched", () => + Effect.gen(function*() { + const test = yield* McpConformance + const cases = [ + [request("missing", "tools/list"), { "MCP-Protocol-Version": protocol.protocolVersion }], + [request("mismatch", "tools/list"), headers("tools/call")], + [ + request("malformed", "resources/read", { uri: "file:///conformance.txt" }), + headers("resources/read", "=?base64?not-valid!?=") + ] + ] as const + + // SEP-2243: https://modelcontextprotocol.io/seps/2243-http-standardization + // The final specification assigns HeaderMismatch error code -32020. + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#request-metadata + for (const [body, requestHeaders] of cases) { + const response = yield* test.post(body, requestHeaders) + const error = yield* decodeError(response) + assert.strictEqual(response.status, 400) + assert.strictEqual(error.id, body.id) + assert.strictEqual(error.error.code, -32020) + } + })) + + it.effect("should reject an unsupported request version with the supported versions", () => + Effect.gen(function*() { + const test = yield* McpConformance + const body = { + ...request(5, "tools/list"), + params: { + _meta: { + ...metadata, + "io.modelcontextprotocol/protocolVersion": "2099-01-01" + } + } + } + const response = yield* test.post(body, { + "MCP-Protocol-Version": "2099-01-01", + "Mcp-Method": "tools/list" + }) + const error = yield* decodeError(response) + + assert.strictEqual(response.status, 400) + assert.strictEqual(error.id, 5) + assert.strictEqual(error.error.code, -32022) + assert.deepStrictEqual(error.error.data, { + supported: ["2026-07-28"], + requested: "2099-01-01" + }) + })) + + it.effect("should return method not found when a request method is unknown or unserved", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post(request(6, "unknown/method"), headers("unknown/method")) + const error = yield* decodeError(response) + assert.strictEqual(response.status, 404) + assert.strictEqual(error.id, 6) + assert.strictEqual(error.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + })) + }) + + describe("Request metadata", () => { + it.effect("should preserve caller metadata alongside authoritative protocol facts", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: 8, + method: "tools/call", + params: { + name: "RequestMetadataTool", + arguments: {}, + _meta: { sentinel: "preserved" } + } + }) + const message = yield* test.decodeResult(response) + const result = Schema.decodeUnknownSync(Schema.Struct({ structuredContent: Schema.String }))(message.result) + const observed = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Unknown))( + JSON.parse(result.structuredContent) + ) + + assert.strictEqual(observed.sentinel, "preserved") + assert.strictEqual(observed["io.modelcontextprotocol/protocolVersion"], protocol.protocolVersion) + assert.deepStrictEqual(observed["io.modelcontextprotocol/clientCapabilities"], {}) + assert.deepStrictEqual(observed["io.modelcontextprotocol/clientInfo"], { + name: "McpConformanceClient", + version: "1.0.0" + }) + })) + }) +}) From 96e534e037338025b9a8c1c3a64e2dc01d21d15a Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 15 Aug 2026 11:24:05 +0200 Subject: [PATCH 8/9] fix: make MCP list-change scheduling deterministic --- .changeset/tidy-clocks-notify.md | 5 +++ packages/effect/src/unstable/ai/McpServer.ts | 27 ++++++------ .../unstable/ai/McpServer/McpServer.test.ts | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 .changeset/tidy-clocks-notify.md diff --git a/.changeset/tidy-clocks-notify.md b/.changeset/tidy-clocks-notify.md new file mode 100644 index 00000000000..6ffe55066e0 --- /dev/null +++ b/.changeset/tidy-clocks-notify.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +MCP servers now coalesce registration list-change notifications deterministically so delayed setup events do not leak into later subscriptions. diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 56f34afd2d1..ee817f3644b 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -303,33 +303,32 @@ export class McpServer extends Context.Service }> = [] const notificationsQueue = yield* Queue.make() - const listChangedHandles = new Map() + const pendingListChanged = new Set() const notifications = yield* RpcClient.makeNoSerialization(BroadcastServerNotificationRpcs, { spanPrefix: "McpServer/Notifications", - onFromClient: (options) => - Effect.suspend((): Effect.Effect => { + onFromClient: (options): Effect.Effect => + Effect.gen(function*() { const message = options.message if (message._tag !== "Request") { - return Effect.void + return } const notification = toInternalServerNotification(message) if (notification === undefined) { - return Effect.void + return } if (message.tag.includes("list_changed")) { - if (!listChangedHandles.has(message.tag)) { - listChangedHandles.set( - message.tag, - setTimeout(() => { - Queue.offerUnsafe(notificationsQueue, { notification }) - listChangedHandles.delete(message.tag) - }, 0) + if (!pendingListChanged.has(message.tag)) { + pendingListChanged.add(message.tag) + yield* Effect.sleep(0).pipe( + Effect.andThen(Queue.offer(notificationsQueue, { notification })), + Effect.ensuring(Effect.sync(() => pendingListChanged.delete(message.tag))), + Effect.forkDetach ) } } else { - Queue.offerUnsafe(notificationsQueue, { notification }) + yield* Queue.offer(notificationsQueue, { notification }) } - return notifications.write({ + yield* notifications.write({ clientId: 0, requestId: message.id, _tag: "Exit", diff --git a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 03800e8a63d..c77bba03840 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -26,6 +26,7 @@ import type * as RpcMessage from "effect/unstable/rpc/RpcMessage" import * as RpcServer from "effect/unstable/rpc/RpcServer" import { makeHttpHarness } from "./TestUtils/McpHttpHarness.ts" import { makeServerLayer } from "./TestUtils/McpServerLayer.ts" +import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" const OptionalStringTool = Tool.make("OptionalStringTool", { parameters: Schema.Struct({ signature: Schema.optional(Schema.String) }), @@ -583,6 +584,47 @@ describe("McpServer", () => { })) }) + describe("list-change notification scheduling", () => { + it.effect("should coalesce notifications when one registration kind changes repeatedly in a scheduling window", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(McpProtocol.v2026_07_28) + const makeTool = (name: string) => ({ + tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }), + annotations: Context.empty(), + handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + }) + const makePrompt = (name: string) => ({ + prompt: new McpSchema.Prompt({ name }), + annotations: Context.empty(), + completions: {}, + handle: () => + Effect.succeed( + new McpSchema.GetPromptResult({ + messages: [{ role: "user", content: { type: "text", text: name } }] + }) + ) + }) + + yield* fixture.server.addTool(makeTool("baseline-tool")) + yield* fixture.server.addPrompt(makePrompt("baseline-prompt")) + yield* fixture.flushListChanged + yield* fixture.initialize() + const subscription = yield* fixture.startRequest("subscriptions/listen", { + notifications: { toolsListChanged: true, promptsListChanged: true } + }, "coalesced-list-change") + assert.strictEqual((yield* fixture.takeMessage).method, "notifications/subscriptions/acknowledged") + + yield* fixture.server.addTool(makeTool("coalesced-tool-first")) + yield* fixture.server.addTool(makeTool("coalesced-tool-second")) + yield* fixture.server.addPrompt(makePrompt("coalescing-sentinel")) + yield* fixture.flushListChanged + + assert.strictEqual((yield* fixture.takeMessage).method, "notifications/tools/list_changed") + assert.strictEqual((yield* fixture.takeMessage).method, "notifications/prompts/list_changed") + yield* subscription.cancel() + })) + }) + describe("resource subscriptions", () => { it.effect("should isolate resource subscriptions and clear disconnected sessions", () => Effect.gen(function*() { From ebcfcb45cb9ae1c1b9725598caa27ec2e8747657 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Mon, 17 Aug 2026 08:16:21 +0200 Subject: [PATCH 9/9] refactor: refinements from feedback --- packages/effect/src/unstable/ai/McpServer.ts | 26 ++- .../ai/internal/mcpProtocol/v2025_06_18.ts | 8 +- .../ai/internal/mcpProtocol/v2025_11_25.ts | 8 +- .../ai/internal/mcpProtocol/v2026_07_28.ts | 34 ++- .../src/unstable/ai/internal/mcpRuntime.ts | 12 + .../McpConformance/BaseProtocolTest.ts | 214 ++++++++++++++++-- .../McpServer/McpConformance/LoggingTest.ts | 83 +++++++ .../McpConformance/McpConformance.ts | 23 +- .../McpConformance/MultiRoundTripTest.ts | 26 +++ .../ai/McpServer/McpConformance/ToolsTest.ts | 23 ++ .../McpConformance/TransportsTest.ts | 131 +++++++++++ .../McpServer/McpConformance/UtilitiesTest.ts | 77 +++++++ .../unstable/ai/McpServer/v2024_11_05.test.ts | 1 + .../unstable/ai/McpServer/v2025_03_26.test.ts | 1 + .../unstable/ai/McpServer/v2025_06_18.test.ts | 1 + .../unstable/ai/McpServer/v2025_11_25.test.ts | 1 + .../unstable/ai/McpServer/v2026_07_28.test.ts | 199 +++++++++++++++- 17 files changed, 819 insertions(+), 49 deletions(-) diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index ee817f3644b..9e7d2521475 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -102,12 +102,14 @@ type ServerNotificationRequest< > = R extends Rpc.Any ? RpcMessage.Request : never const BroadcastServerNotificationRpcs = ServerNotificationRpcs.omit("notifications/elicitation/complete") +const isJson = Schema.is(Schema.Json) +const isLoggingLevel = Schema.is(McpSchema.LoggingLevel) const validateStructuredContent = ( toolName: string, value: unknown ): Effect.Effect => - Schema.is(Schema.Json)(value) + isJson(value) ? Effect.succeed(value) : Effect.fail( new McpCore.ToolResultProjectionError({ @@ -161,7 +163,27 @@ const provideInvocationContext = ( effect: Effect.Effect, invocation: McpCore.McpInvocation ): Effect.Effect> => { - const provided = Effect.provideService(effect, McpRequestContext, invocation.requestContext) + let provided = Effect.provideService(effect, McpRequestContext, invocation.requestContext) + const requestMetadata = invocation.requestContext.requestMetadata + const logLevel = Predicate.hasProperty(requestMetadata, "io.modelcontextprotocol/logLevel") + ? requestMetadata["io.modelcontextprotocol/logLevel"] + : undefined + if (isLoggingLevel(logLevel)) { + provided = Effect.provideService( + provided, + CurrentLogLevel, + { + debug: "Debug", + info: "Info", + notice: "Info", + warning: "Warn", + error: "Error", + critical: "Fatal", + alert: "Fatal", + emergency: "Fatal" + }[logLevel] + ) + } return (invocation.serverClient === undefined ? provided : Effect.provideService(provided, McpServerClient, invocation.serverClient)) as Effect.Effect< diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts index 0b8af38ed68..5e307cac4a4 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -16,6 +16,9 @@ const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) const AdapterRpcs = ClientRpcs.omit("ping") +const JsonObject = Schema.Record(Schema.String, Schema.Json) +const isJsonObject = Schema.is(JsonObject) +const isToolOutputSchema = Schema.is(McpSchema.Tool.fields.outputSchema) const profileFromInitialize = ( initialize: typeof McpSchema.Initialize.payloadSchema.Type @@ -108,9 +111,6 @@ const projectStructuredContent = ( content: Schema.Json | undefined ): Schema.JsonObject | undefined => content === undefined || isJsonObject(content) ? content : undefined -const isJsonObject = (value: Schema.Json): value is Schema.JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value) - /** @internal */ export const protocol = McpProtocol.make({ protocolVersion: McpSchema.protocolVersion, @@ -243,7 +243,7 @@ export const protocol = McpProtocol.make({ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, - outputSchema: Schema.is(McpSchema.Tool.fields.outputSchema)(tool.outputSchema) + outputSchema: isToolOutputSchema(tool.outputSchema) ? tool.outputSchema : undefined, annotations: tool.annotations === undefined diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts index 9593def537b..778ee764cf9 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_11_25.ts @@ -15,6 +15,9 @@ const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) const AdapterRpcs = ClientRpcs.omit("ping") +const JsonObject = Schema.Record(Schema.String, Schema.Json) +const isJsonObject = Schema.is(JsonObject) +const isToolOutputSchema = Schema.is(McpSchema.Tool.fields.outputSchema) const unsupported = ( operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], @@ -106,8 +109,7 @@ const projectContent = Effect.fnUntraced(function*(content: typeof PublicMcpSche const projectStructuredContent = ( content: Schema.Json | undefined -): Schema.JsonObject | undefined => - content === undefined || Schema.is(Schema.Record(Schema.String, Schema.Json))(content) ? content : undefined +): Schema.JsonObject | undefined => content === undefined || isJsonObject(content) ? content : undefined /** @internal */ export const protocol = McpProtocol.make({ @@ -274,7 +276,7 @@ export const protocol = McpProtocol.make({ title: tool.title, description: tool.description, inputSchema: tool.inputSchema, - outputSchema: Schema.is(McpSchema.Tool.fields.outputSchema)(tool.outputSchema) + outputSchema: isToolOutputSchema(tool.outputSchema) ? tool.outputSchema : undefined, icons: tool.icons, diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts index 1ce9d5ce4e2..f64bd7e745b 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2026_07_28.ts @@ -3,6 +3,7 @@ * * @internal */ +import * as Arr from "../../../../Array.ts" import * as Effect from "../../../../Effect.ts" import * as Encoding from "../../../../Encoding.ts" import * as Match from "../../../../Match.ts" @@ -18,6 +19,8 @@ import * as McpSchema from "../mcpSchema/v2026_07_28.ts" const JsonObject = Schema.Record(Schema.String, Schema.Json) const InputResponses = Schema.Record(Schema.String, JsonObject) const decodeRequestMetadata = Schema.decodeUnknownEffect(McpSchema.RequestMetaObject) +const isJson = Schema.is(Schema.Json) +const isJsonObject = Schema.is(JsonObject) type ObjectWithUndefined = Readonly> @@ -55,7 +58,7 @@ const resultMetadata = ( ): Schema.JsonObject => { const metadata: unknown = value._meta return { - ...(Schema.is(JsonObject)(metadata) ? metadata : {}), + ...(isJsonObject(metadata) ? metadata : {}), "io.modelcontextprotocol/serverInfo": serverInfo } } @@ -179,7 +182,7 @@ export const projectError = (error: unknown): typeof McpSchema.McpError.Type => return McpSchema.McpError.make({ code: protocolError.code, message: protocolError.message, - ...(Schema.is(Schema.Json)(protocolError.data) ? { data: protocolError.data } : {}) + ...(isJson(protocolError.data) ? { data: protocolError.data } : {}) }) } @@ -485,6 +488,33 @@ export const makeHandlers = ( }) ) if (outcome._tag === "InputRequired") { + const capabilities = invocation.protocol.clientCapabilities + const noneRequired: Record = {} + const requiredCapabilities = Arr.reduce( + Object.values(outcome.inputRequests), + noneRequired, + (required, request) => + Match.value(request).pipe( + Match.when({ method: "roots/list" }, () => + capabilities.roots === undefined ? { ...required, roots: {} } : required), + Match.when({ method: "sampling/createMessage" }, () => + capabilities.sampling === undefined ? { ...required, sampling: {} } : required), + Match.when({ method: "elicitation/create" }, (request) => { + const mode = request.params.mode === "url" ? "url" : "form" + return capabilities.elicitation?.[mode] === undefined + ? { ...required, elicitation: { ...required.elicitation, [mode]: {} } } + : required + }), + Match.exhaustive + ) + ) + if (Object.keys(requiredCapabilities).length > 0) { + return yield* new McpProtocol.ProtocolError({ + code: McpSchema.MISSING_REQUIRED_CLIENT_CAPABILITY, + message: "The request requires client capabilities that were not declared", + data: { requiredCapabilities } + }) + } return yield* projectCallToolOutcome(outcome, discovery.serverInfo) } const toolResult = outcome.value diff --git a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts index 96eb4e21574..74c2a048bf3 100644 --- a/packages/effect/src/unstable/ai/internal/mcpRuntime.ts +++ b/packages/effect/src/unstable/ai/internal/mcpRuntime.ts @@ -29,6 +29,7 @@ const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" const MCP_METHOD_HEADER = "mcp-method" const MCP_NAME_HEADER = "mcp-name" const PROTOCOL_VERSION_METADATA_KEY = "io.modelcontextprotocol/protocolVersion" +const CLIENT_CAPABILITIES_METADATA_KEY = "io.modelcontextprotocol/clientCapabilities" const BASE64_SENTINEL_PREFIX = "=?base64?" const BASE64_SENTINEL_SUFFIX = "?=" @@ -294,6 +295,17 @@ export const make = Effect.fnUntraced(function*( if (typeof claim.value !== "string" || claim.value !== protocolVersion) { return headerMismatch("MCP-Protocol-Version header does not match request metadata") } + const metadata = asRecord(asRecord(asRecord(input)?.params)?._meta) + if (asRecord(metadata?.[CLIENT_CAPABILITIES_METADATA_KEY]) === undefined) { + return { + _tag: "Rejected", + status: 400, + error: { + code: -32602, + message: `${CLIENT_CAPABILITIES_METADATA_KEY} request metadata is required` + } + } + } if (statelessProtocol === undefined || protocolVersion !== statelessProtocol.protocolVersion) { return { _tag: "Rejected", diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/BaseProtocolTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/BaseProtocolTest.ts index b2e9f5b1060..b791d5285b1 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/BaseProtocolTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/BaseProtocolTest.ts @@ -10,6 +10,198 @@ import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts" import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Base Protocol > General fields", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/basic#general-fields + it.effect("should preserve additional result metadata fields when decoding a result", () => + Effect.gen(function*() { + const result = yield* Schema.decodeUnknownEffect(McpSchema.ReadResourceResult)({ + contents: [], + _meta: { + "example/conformance": { + enabled: true, + labels: ["one", "two"] + } + } + }) + + assert.deepStrictEqual(result._meta, { + "example/conformance": { + enabled: true, + labels: ["one", "two"] + } + }) + })) + }) + }) + +export const statelessModernSuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + const requestMetadata = { + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "stdio-client", version: "1.0.0" } + } + + describe("Base Protocol > Stateless messages", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests + it.effect("should preserve string and numeric identifiers when requests succeed", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + for (const id of ["discover-1", 42] as const) { + const message = yield* fixture.sendRequest("server/discover", {}, id) + assert.strictEqual(message.id, id) + } + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests + it.effect("should reject a request when its JSON-RPC version is invalid", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize() + const response = yield* test.send(discovered, { + jsonrpc: "1.0", + id: 2, + method: "server/discover", + params: {} + }) + const message = yield* test.decodeError(response) + + assert.strictEqual(message.id, 2) + assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests + it.effect("should reject a request when its identifier is not a string or integer", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize() + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: true, + method: "server/discover", + params: {} + }) + const message = yield* test.decodeError(response) + + assert.strictEqual(message.id, null) + assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests + it.effect("should return method not found when the requested method is unknown", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const message = yield* fixture.sendRequest("unknown/method", {}, 3) + + assert.strictEqual(message.id, 3) + const error = Schema.decodeUnknownSync(McpSchema.McpError)(message.error) + assert.strictEqual(error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#requests + it.effect("should return invalid params when request parameters do not match the method schema", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const message = yield* fixture.sendRequest("tools/list", { cursor: 1 }, 7) + + assert.strictEqual(message.id, 7) + const error = Schema.decodeUnknownSync(McpSchema.McpError)(message.error) + assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#notifications + it.effect("should send no response when an unknown notification is received", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendNotification("unknown/method", {}) + const response = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild) + yield* TestClock.adjust("1 millis") + + assert.isTrue(Option.isNone(yield* Fiber.join(response))) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#notifications + it.effect("should send no response when notification parameters are invalid", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendNotification("notifications/cancelled", { requestId: true }) + const response = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild) + yield* TestClock.adjust("1 millis") + + assert.isTrue(Option.isNone(yield* Fiber.join(response))) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#responses + it.effect("should send exactly one result response when a request succeeds", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 4, + method: "server/discover", + params: { _meta: requestMetadata } + }) + const message = yield* fixture.takeMessage + assert.strictEqual(message.id, 4) + assert.property(message, "result") + assert.notProperty(message, "error") + + const duplicate = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild) + yield* TestClock.adjust("1 millis") + assert.isTrue(Option.isNone(yield* Fiber.join(duplicate))) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#responses + it.effect("should send exactly one error response when a request fails", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 8, + method: "unknown/method", + params: { _meta: requestMetadata } + }) + const message = yield* fixture.takeMessage + assert.strictEqual(message.id, 8) + assert.property(message, "error") + assert.notProperty(message, "result") + + const duplicate = yield* fixture.takeMessage.pipe(Effect.timeoutOption("1 millis"), Effect.forkChild) + yield* TestClock.adjust("1 millis") + assert.isTrue(Option.isNone(yield* Fiber.join(duplicate))) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#messages + it.effect("should return a parse error when a JSON message is malformed", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize() + const response = yield* test.sendText(discovered, "{") + const message = yield* test.decodeError(response) + + assert.strictEqual(message.id, null) + assert.strictEqual(message.error.code, McpSchema.PARSE_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#messages + it.effect("should return invalid request when a JSON-RPC message omits its method", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize() + const response = yield* test.send(discovered, { jsonrpc: "2.0", id: 10, params: {} }) + const message = yield* test.decodeError(response) + + assert.strictEqual(message.id, 10) + assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) + })) + }) + }) + +export const statefulLegacySuite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Base Protocol", () => { // https://modelcontextprotocol.io/specification/2025-06-18/basic @@ -273,27 +465,5 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual(message.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) })) }) - - describe("General fields", () => { - it.effect("SCHEMA preserves additional result metadata fields", () => - Effect.gen(function*() { - const result = yield* Schema.decodeUnknownEffect(McpSchema.ReadResourceResult)({ - contents: [], - _meta: { - "example/conformance": { - enabled: true, - labels: ["one", "two"] - } - } - }) - - assert.deepStrictEqual(result._meta, { - "example/conformance": { - enabled: true, - labels: ["one", "two"] - } - }) - })) - }) }) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts index 9aead410d39..2054324f921 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/LoggingTest.ts @@ -8,6 +8,16 @@ import { makeMcpStdioHarness } from "../TestUtils/McpStdioHarness.ts" import { McpConformance, type McpConformanceLayer } from "./McpConformance.ts" const levels = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] as const +const effectLevels = { + debug: "Debug", + info: "Info", + notice: "Info", + warning: "Warn", + error: "Error", + critical: "Fatal", + alert: "Fatal", + emergency: "Fatal" +} as const const setLevel = (level: string) => Effect.gen(function*() { @@ -201,3 +211,76 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) }) }) + +export const statelessModernSuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + const callLogLevelTool = Effect.fnUntraced(function*(level?: string) { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "LogLevelTool", + arguments: {}, + ...(level === undefined + ? {} + : { _meta: { "io.modelcontextprotocol/logLevel": level } }) + } + }) + return { response, test } + }) + + describe("Logging > Stateless modern", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#capabilities + it.effect("should advertise logging when request-scoped log filtering is supported", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + + assert.property(discovered.message.result.capabilities, "logging") + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-levels + it.effect("should apply every specified log level to the request that declares it", () => + Effect.forEach(levels, (level) => + Effect.gen(function*() { + const { response, test } = yield* callLogLevelTool(level) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => Schema.decodeUnknownEffect(McpSchema.CallToolResult)(message.result)) + ) + assert.deepStrictEqual(result.content, [{ type: "text", text: JSON.stringify(effectLevels[level]) }]) + }), { concurrency: 1 })) + + // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-levels + it.effect("should reject a request when its request-scoped log level is unknown", () => + Effect.gen(function*() { + const { response, test } = yield* callLogLevelTool("verbose") + const error = yield* test.decodeError(response) + + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#log-message-notifications + it.effect("should preserve the level, logger, and JSON data when decoding a log notification", () => + Effect.gen(function*() { + const payload = yield* Schema.decodeUnknownEffect( + McpSchema.LoggingMessageNotification.payloadSchema + )({ + level: "warning", + logger: "database", + data: { message: "slow query", durationMs: 120 } + }) + + assert.deepStrictEqual(payload, { + level: "warning", + logger: "database", + data: { message: "slow query", durationMs: 120 } + }) + })) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts index cffbf4eb6fb..a69f352b70e 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/McpConformance.ts @@ -47,13 +47,11 @@ const decodeErrorResponse = Schema.decodeUnknownEffect(ErrorResponse) const decodeResultResponse = Schema.decodeUnknownEffect(ResultResponse) const decodeBatchResponse = Schema.decodeUnknownEffect(BatchResponse) -const requestMetadata = (protocol: McpProtocol.ProtocolAdapter) => ({ - "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, - "io.modelcontextprotocol/clientCapabilities": {}, - "io.modelcontextprotocol/clientInfo": { name: "McpConformanceClient", version: "1.0.0" } -}) - -const statelessBody = (protocol: McpProtocol.ProtocolAdapter, body: unknown): unknown => { +const statelessBody = ( + protocol: McpProtocol.ProtocolAdapter, + body: unknown, + clientCapabilities?: Record +): unknown => { if ( protocol.runtime._tag !== "Stateless" || typeof body !== "object" || @@ -75,7 +73,13 @@ const statelessBody = (protocol: McpProtocol.ProtocolAdapter, body: unknown): un ...params, _meta: { ...metadata, - ...requestMetadata(protocol) + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": clientCapabilities ?? { + elicitation: { form: {} }, + roots: {}, + sampling: {} + }, + "io.modelcontextprotocol/clientInfo": { name: "McpConformanceClient", version: "1.0.0" } } } } @@ -122,6 +126,7 @@ export interface InitializeOptions { export interface SendOptions { readonly includeProtocolVersion?: boolean | undefined readonly protocolVersion?: string | undefined + readonly clientCapabilities?: Record | undefined } export interface McpConformanceShape { @@ -305,7 +310,7 @@ export const layer = (protocol: McpProtocol.ProtocolAdapter) => if (protocol.runtime._tag !== "Stateless") { return post(harnessFor(session), body, sessionHeaders(session, options)) } - const request = statelessBody(protocol, body) + const request = statelessBody(protocol, body, options?.clientCapabilities) return post(harnessFor(session), request, statelessHeaders(protocol, request)) } diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts index 948e8b20f28..e96b1a0d8cb 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/MultiRoundTripTest.ts @@ -135,5 +135,31 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.strictEqual(result.requestState, mrtrRequestState) } })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-validation-and-security + it.effect("should reject a continuation when its input responses or request state are malformed", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const cases = [ + { inputResponses: { approval: "accepted" }, requestState: mrtrRequestState }, + { inputResponses, requestState: 42 } + ] as const + + for (const [index, continuation] of cases.entries()) { + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: index + 20, + method: "tools/call", + params: { + name: MrtrToolName, + arguments: {}, + ...continuation + } + }) + const error = yield* test.decodeError(response) + assert.strictEqual(error.error.code, -32602) + } + })) }) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts index 8df0e2e06c6..9ca82f2820e 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts @@ -451,6 +451,29 @@ export const statelessModernSuite = ( assert.strictEqual((yield* test.observations).toolInvocations, before) })) }) + + describe("Tools > Modern listing", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/server/tools#listing-tools + it.effect("should return tools in the same order when the registered inventory has not changed", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize({ server: "features" }) + const list = Effect.fnUntraced(function*() { + const response = yield* test.send(initialized, { + jsonrpc: "2.0", + id: 20, + method: "tools/list", + params: {} + }) + const result = yield* test.decodeResult(response).pipe( + Effect.flatMap((message) => decodeTools(message.result)) + ) + return result.tools.map((tool) => tool.name) + }) + + assert.deepStrictEqual(yield* list(), yield* list()) + })) + }) }) const invalidArgumentsResponse = Effect.fnUntraced(function*() { diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts index 9593fec3a68..f468d1d659d 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts @@ -449,3 +449,134 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) }) }) + +export const statelessModernSuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + const metadata = { + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: "transport-client", version: "1.0.0" } + } + const request = (id: string | number, method = "server/discover") => ({ + jsonrpc: "2.0", + id, + method, + params: { _meta: metadata } + }) + const headers = (method = "server/discover"): HeadersInit => ({ + "MCP-Protocol-Version": protocol.protocolVersion, + "Mcp-Method": method + }) + + describe("Transports > Stateless modern", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio + it.effect("should exchange one compact newline-delimited JSON-RPC message per stdio line", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const response = yield* fixture.sendRequest("server/discover", {}, 1).pipe(Effect.forkChild) + const frame = yield* fixture.takeRawStdout + + assert.strictEqual(frame.endsWith("\n"), true) + assert.strictEqual(frame.slice(0, -1).includes("\n"), false) + assert.deepInclude(JSON.parse(frame), { jsonrpc: "2.0", id: 1 }) + yield* Fiber.join(response) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio + it.effect("should reconstruct a UTF-8 stdio message when its bytes arrive in separate chunks", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + const bytes = new TextEncoder().encode(`${JSON.stringify(request("stdio-🧪"))}\n`) + const splitAt = bytes.indexOf(0xf0) + 2 + + yield* fixture.sendChunk(bytes.slice(0, splitAt)) + yield* fixture.sendChunk(bytes.slice(splitAt)) + + assert.deepInclude(yield* fixture.takeFrame, { jsonrpc: "2.0", id: "stdio-🧪" }) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio + it.effect("should process consecutive stateless stdio requests independently", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw(request(2)) + yield* fixture.sendRaw(request(3)) + + assert.deepInclude(yield* fixture.takeFrame, { jsonrpc: "2.0", id: 2 }) + assert.deepInclude(yield* fixture.takeFrame, { jsonrpc: "2.0", id: 3 }) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio + it.effect("should shut down the stdio server when the client closes stdin", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.close + const exit = yield* Fiber.await(fixture.serverFiber) + + assert.isTrue(Exit.isSuccess(exit) || (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause))) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#sending-messages-to-the-server + it.effect("should require application/json content and both supported response media types for HTTP POST", () => + Effect.gen(function*() { + const test = yield* McpConformance + const body = request(4) + const accepted = yield* test.request(jsonRequest("POST", body, { + ...headers(), + "content-type": "Application/JSON; charset=utf-8", + accept: "Text/Event-Stream, Application/JSON" + })) + assert.strictEqual(accepted.status, 200) + + for (const contentType of ["text/plain", "application/json-malicious", ""]) { + const response = yield* test.request(jsonRequest("POST", body, { + ...headers(), + "content-type": contentType + })) + assert.strictEqual(response.status, 415) + } + for (const accept of ["application/json", "text/event-stream", "*/*", ""]) { + const response = yield* test.request(jsonRequest("POST", body, { ...headers(), accept })) + assert.strictEqual(response.status, 406) + } + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#sending-messages-to-the-server + it.effect("should return application/json when an HTTP request has one JSON-RPC response", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.request(jsonRequest("POST", request(5), headers())) + + assert.strictEqual(response.status, 200) + assert.match(response.headers.get("content-type") ?? "", /^application\/json\b/) + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#listening-for-messages-from-the-server + it.effect("should reject GET and unsupported HTTP methods when only POST is available", () => + Effect.gen(function*() { + const test = yield* McpConformance + for (const method of ["GET", "PUT", "PATCH", "HEAD"] as const) { + const response = yield* test.request(jsonRequest(method)) + assert.strictEqual(response.status, 405) + assert.strictEqual(response.headers.get("allow"), "POST") + } + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#security-warning + it.effect("should reject every MCP HTTP route when its Origin is not explicitly allowed", () => + Effect.gen(function*() { + const test = yield* McpConformance + for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const) { + const response = yield* test.request(jsonRequest( + method, + method === "POST" ? request(6) : undefined, + { ...headers(), origin: "https://attacker.example" } + )) + assert.strictEqual(response.status, 403) + } + })) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/UtilitiesTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/UtilitiesTest.ts index 08194faeb17..b0241407d4f 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/UtilitiesTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/UtilitiesTest.ts @@ -221,3 +221,80 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) }) }) + +export const statelessModernSuite = ( + protocol: McpProtocol.ProtocolAdapter, + layer: McpConformanceLayer +) => + it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Utilities > Stateless modern", () => { + describe("Cancellation", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/basic/utilities/cancellation + it.effect("should send no response when a cancellation notification is received", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize() + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "unknown-request", reason: "No longer needed" } + }) + + assert.strictEqual(response.status, 202) + assert.strictEqual(yield* Effect.promise(() => response.text()), "") + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/utilities/cancellation + it.effect("should interrupt work and suppress its response when an active request is cancelled", () => + Effect.gen(function*() { + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const interrupted = yield* Deferred.make() + yield* Effect.addFinalizer(() => Deferred.succeed(release, void 0)) + const fixture = yield* makeMcpStdioHarness(protocol) + const cancelledRequestId = "cancelled-tool-call" + + yield* fixture.server.addTool({ + tool: new McpSchema.Tool({ + name: "ModernGatedTool", + inputSchema: { type: "object", properties: {} } + }), + annotations: Context.empty(), + handle: () => + Deferred.succeed(entered, void 0).pipe( + Effect.andThen(Deferred.await(release)), + Effect.onInterrupt(() => Deferred.succeed(interrupted, void 0)), + Effect.as(new McpSchema.CallToolResult({ content: [{ type: "text", text: "released" }] })) + ) + }) + + yield* fixture.initialize() + yield* fixture.startRequest("tools/call", { + name: "ModernGatedTool", + arguments: {} + }, cancelledRequestId) + yield* Deferred.await(entered) + yield* fixture.sendNotification("notifications/cancelled", { + requestId: cancelledRequestId, + reason: "No longer needed" + }) + yield* Deferred.await(interrupted) + + const control = yield* fixture.sendRequest("server/discover", {}, "post-cancellation-discover") + assert.strictEqual(control.id, "post-cancellation-discover") + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic/utilities/cancellation + it.effect("should allow a later request to reuse an identifier cancelled before it was active", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.initialize() + yield* fixture.sendNotification("notifications/cancelled", { requestId: "reused-id" }) + + const reused = yield* fixture.sendRequest("server/discover", {}, "reused-id") + assert.strictEqual(reused.id, "reused-id") + assert.property(reused, "result") + })) + }) + }) + }) diff --git a/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts b/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts index 7f458d7e543..f9b6afcd5d7 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts @@ -18,6 +18,7 @@ const testLayer = McpConformance.layer(protocol) LifecycleTest.suite(protocol, testLayer) BaseProtocolTest.suite(protocol, testLayer) +BaseProtocolTest.statefulLegacySuite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts index 4057d1b83bd..3ec7005a43d 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts @@ -21,6 +21,7 @@ const testLayer = makeMcpConformanceLayer(protocol) LifecycleTest.suite(protocol, testLayer) BaseProtocolTest.suite(protocol, testLayer) +BaseProtocolTest.statefulLegacySuite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) LoggingTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts index b8d14587127..789d007a0e6 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_06_18.test.ts @@ -18,6 +18,7 @@ const testLayer = makeMcpConformanceLayer(protocol) LifecycleTest.suite(protocol, testLayer) BaseProtocolTest.suite(protocol, testLayer) +BaseProtocolTest.statefulLegacySuite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts b/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts index 5b675273ec3..4721f78f7c2 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2025_11_25.test.ts @@ -21,6 +21,7 @@ const testLayer = makeMcpConformanceLayer(protocol) LifecycleTest.suite(protocol, testLayer) BaseProtocolTest.suite(protocol, testLayer) +BaseProtocolTest.statefulLegacySuite(protocol, testLayer) TransportsTest.suite(protocol, testLayer) UtilitiesTest.suite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) diff --git a/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts b/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts index 91093760762..c976cd335f9 100644 --- a/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/v2026_07_28.test.ts @@ -4,13 +4,18 @@ import * as Encoding from "effect/Encoding" import * as Schema from "effect/Schema" import * as McpProtocol from "effect/unstable/ai/McpProtocol" import * as McpSchema from "effect/unstable/ai/McpSchema" +import * as BaseProtocolTest from "./McpConformance/BaseProtocolTest.ts" import * as CompletionTest from "./McpConformance/CompletionTest.ts" +import * as LoggingTest from "./McpConformance/LoggingTest.ts" import { layer as makeMcpConformanceLayer, McpConformance } from "./McpConformance/McpConformance.ts" +import { MrtrToolName } from "./McpConformance/McpConformanceFixtures.ts" import * as MultiRoundTripTest from "./McpConformance/MultiRoundTripTest.ts" import * as PromptsTest from "./McpConformance/PromptsTest.ts" import * as ResourcesTest from "./McpConformance/ResourcesTest.ts" import * as SubscriptionsTest from "./McpConformance/SubscriptionsTest.ts" import * as ToolsTest from "./McpConformance/ToolsTest.ts" +import * as TransportsTest from "./McpConformance/TransportsTest.ts" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" const protocol = McpProtocol.v2026_07_28 @@ -47,11 +52,16 @@ const decodeError = (response: Response) => }))) ) +BaseProtocolTest.suite(protocol, testLayer) +BaseProtocolTest.statelessModernSuite(protocol, testLayer) +TransportsTest.statelessModernSuite(protocol, testLayer) +UtilitiesTest.statelessModernSuite(protocol, testLayer) ToolsTest.suite(protocol, testLayer) ToolsTest.statelessModernSuite(protocol, testLayer) ResourcesTest.suite(protocol, testLayer) PromptsTest.suite(protocol, testLayer) CompletionTest.suite(protocol, testLayer) +LoggingTest.statelessModernSuite(protocol, testLayer) MultiRoundTripTest.suite(protocol, testLayer) SubscriptionsTest.suite(protocol, testLayer) @@ -186,18 +196,65 @@ it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { }) })) - it.effect("should return method not found when a request method is unknown or unserved", () => + // https://modelcontextprotocol.io/specification/2026-07-28/changelog#removals + it.effect("should return method not found when a request uses an unknown or removed method", () => Effect.gen(function*() { const test = yield* McpConformance - const response = yield* test.post(request(6, "unknown/method"), headers("unknown/method")) - const error = yield* decodeError(response) - assert.strictEqual(response.status, 404) - assert.strictEqual(error.id, 6) - assert.strictEqual(error.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + const methods = [ + "unknown/method", + "ping", + "logging/setLevel", + "resources/subscribe", + "resources/unsubscribe", + "tasks/get" + ] as const + + for (const [index, method] of methods.entries()) { + const id = index + 6 + const response = yield* test.post(request(id, method), headers(method)) + const error = yield* decodeError(response) + assert.strictEqual(response.status, 404, method) + assert.strictEqual(error.id, id, method) + assert.strictEqual(error.error.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE, method) + } })) }) describe("Request metadata", () => { + it.effect("should reject requests when required protocol metadata is missing", () => + Effect.gen(function*() { + const test = yield* McpConformance + const body = request(20, "tools/list") + const response = yield* test.post({ + ...body, + params: { + _meta: { "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion } + } + }, headers("tools/list")) + const error = yield* decodeError(response) + + assert.strictEqual(response.status, 400) + assert.strictEqual(error.id, body.id) + assert.strictEqual(error.error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + })) + + it.effect("should accept a request when optional client identity is omitted", () => + Effect.gen(function*() { + const test = yield* McpConformance + const body = request(22, "tools/list") + const response = yield* test.post({ + ...body, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }, headers("tools/list")) + + assert.strictEqual(response.status, 200) + })) + it.effect("should preserve caller metadata alongside authoritative protocol facts", () => Effect.gen(function*() { const test = yield* McpConformance @@ -220,11 +277,139 @@ it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { assert.strictEqual(observed.sentinel, "preserved") assert.strictEqual(observed["io.modelcontextprotocol/protocolVersion"], protocol.protocolVersion) - assert.deepStrictEqual(observed["io.modelcontextprotocol/clientCapabilities"], {}) + assert.deepStrictEqual(observed["io.modelcontextprotocol/clientCapabilities"], { + elicitation: { form: {} }, + roots: {}, + sampling: {} + }) assert.deepStrictEqual(observed["io.modelcontextprotocol/clientInfo"], { name: "McpConformanceClient", version: "1.0.0" }) })) }) + + describe("Result envelopes", () => { + // SEP-2322 requires every successful result to declare its result type. + // SEP-2549 requires cache metadata on discovery, list, and resource-read results. + it.effect("should attach modern result and cache metadata to every cacheable operation", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + const cases = [ + ["server/discover", {}], + ["tools/list", {}], + ["prompts/list", {}], + ["resources/list", {}], + ["resources/templates/list", {}], + ["resources/read", { uri: "file:///test" }] + ] as const + + for (const [index, [method, params]] of cases.entries()) { + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: index + 30, + method, + params + }) + assert.strictEqual(response.status, 200, method) + const body = yield* Effect.promise(() => response.json()) + assert.isObject(body, method) + assert.property(body, "result", method) + const result = Schema.decodeUnknownSync(Schema.Struct({ + resultType: Schema.Literal("complete"), + ttlMs: Schema.Int, + cacheScope: Schema.Literals(["public", "private"]), + _meta: Schema.Struct({ + "io.modelcontextprotocol/serverInfo": Schema.Struct({ + name: Schema.String, + version: Schema.String + }) + }) + }))((body as { result: unknown }).result) + + assert.isAtLeast(result.ttlMs, 0) + assert.deepStrictEqual(result._meta["io.modelcontextprotocol/serverInfo"], { + name: "McpConformance", + version: "1.0.0" + }) + } + })) + + // https://modelcontextprotocol.io/specification/2026-07-28/basic#results + it.effect("should attach a complete result type and server identity to every non-cacheable operation", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + const cases = [ + ["tools/call", { name: "TestTool", arguments: { value: "called" } }], + ["prompts/get", { name: "NoArgumentPrompt", arguments: {} }], + [ + "completion/complete", + { + ref: { type: "ref/prompt", name: "TestPrompt" }, + argument: { name: "required", value: "f" } + } + ] + ] as const + + for (const [index, [method, params]] of cases.entries()) { + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: index + 50, + method, + params + }) + const message = yield* test.decodeResult(response) + const result = Schema.decodeUnknownSync(Schema.Struct({ + resultType: Schema.Literal("complete"), + _meta: Schema.Struct({ + "io.modelcontextprotocol/serverInfo": Schema.Struct({ + name: Schema.String, + version: Schema.String + }) + }) + }))(message.result) + + assert.deepStrictEqual(result._meta["io.modelcontextprotocol/serverInfo"], { + name: "McpConformance", + version: "1.0.0" + }) + } + })) + }) + + describe("Multi round-trip request capabilities", () => { + // https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-capability-validation + it.effect("should reject input requests when the client omits their required capabilities", () => + Effect.gen(function*() { + const test = yield* McpConformance + const discovered = yield* test.initialize({ server: "features" }) + const response = yield* test.send(discovered, { + jsonrpc: "2.0", + id: 40, + method: "tools/call", + params: { name: MrtrToolName, arguments: {} } + }, { clientCapabilities: {} }) + const error = yield* decodeError(response) + + assert.strictEqual(error.error.code, -32021) + const cause = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ + _tag: Schema.Literal("Fail"), + error: Schema.Struct({ + data: Schema.Struct({ + requiredCapabilities: Schema.Record(Schema.String, Schema.Json) + }) + }) + })))(error.error.data) + assert.lengthOf(cause, 1) + assert.deepStrictEqual(cause[0]?.error.data, { + requiredCapabilities: { + elicitation: { form: {} }, + sampling: {}, + roots: {} + } + }) + })) + }) })