From 307e4e50157f338c07f5b273b94e46067e9367fd Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 1 Aug 2026 07:24:13 +0200 Subject: [PATCH 1/5] feat: add versioned MCP wire schemas --- .../ai/internal/mcpSchema/v2024_11_05.ts | 521 ++++++++++++++++++ .../ai/internal/mcpSchema/v2025_03_26.ts | 204 +++++++ .../ai/internal/mcpSchema/v2025_06_18.ts | 404 ++++++++++++++ 3 files changed, 1129 insertions(+) create mode 100644 packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts diff --git a/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts b/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts new file mode 100644 index 00000000000..43d0bb05b96 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpSchema/v2024_11_05.ts @@ -0,0 +1,521 @@ +/** + * Exact MCP v2024-11-05 wire schemas. + * + * Transport topology is intentionally not represented here. This module owns + * the dated JSON-RPC method payloads and results only. + * + * @internal + */ +import * as Option from "../../../../Option.ts" +import * as Schema from "../../../../Schema.ts" +import * as SchemaGetter from "../../../../SchemaGetter.ts" +import * as Rpc from "../../../rpc/Rpc.ts" +import * as RpcGroup from "../../../rpc/RpcGroup.ts" + +export const protocolVersion = "2024-11-05" + +export const optional = ( + schema: S +): Schema.decodeTo, Schema.optionalKey> => + Schema.optionalKey(schema).pipe( + Schema.decodeTo(Schema.optional(schema), { + decode: SchemaGetter.passthrough(), + encode: SchemaGetter.transformOptional(Option.flatMap(Option.fromUndefinedOr)) + }) + ) +const JsonObject = Schema.Record(Schema.String, Schema.Json) + +export const RequestId = Schema.Union([Schema.String, Schema.Finite]) +export const ProgressToken = Schema.Union([Schema.String, Schema.Finite]) +export const Role = Schema.Literals(["user", "assistant"]) +export const LoggingLevel = Schema.Literals([ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency" +]) + +export const RequestMeta = Schema.Struct({ + _meta: optional(Schema.Struct({ + progressToken: optional(ProgressToken) + })) +}) + +export const NotificationMeta = Schema.Struct({ + _meta: optional(JsonObject) +}) + +export const ResultMeta = Schema.Struct({ + _meta: optional(JsonObject) +}) + +export const PaginatedRequest = Schema.Struct({ + ...RequestMeta.fields, + cursor: optional(Schema.String) +}) + +export const PaginatedResult = Schema.Struct({ + ...ResultMeta.fields, + nextCursor: optional(Schema.String) +}) + +export const Implementation = Schema.Struct({ + name: Schema.String, + version: Schema.String +}) + +export const ClientCapabilities = Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + roots: optional(Schema.Struct({ + listChanged: optional(Schema.Boolean) + })), + sampling: optional(JsonObject) +}) + +export const ServerCapabilities = Schema.Struct({ + experimental: optional(Schema.Record(Schema.String, JsonObject)), + logging: 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) + })) +}) + +export const McpError = Schema.Struct({ + code: Schema.Int, + message: Schema.String, + data: optional(Schema.Any) +}) +export type McpError = typeof McpError.Type + +export const Annotation = Schema.Struct({ + audience: optional(Schema.Array(Role)), + priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const TextResourceContents = Schema.Struct({ + uri: Schema.String, + mimeType: optional(Schema.String), + text: Schema.String +}) + +export const BlobResourceContents = Schema.Struct({ + uri: Schema.String, + mimeType: optional(Schema.String), + blob: Schema.String +}) + +export const ResourceContents = Schema.Union([ + TextResourceContents, + BlobResourceContents +]) + +export const TextContent = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + annotations: optional(Annotation) +}) + +export const ImageContent = Schema.Struct({ + type: Schema.Literal("image"), + data: Schema.String, + mimeType: Schema.String, + annotations: optional(Annotation) +}) + +export const EmbeddedResource = Schema.Struct({ + type: Schema.Literal("resource"), + resource: ResourceContents, + annotations: optional(Annotation) +}) + +export const PromptOrToolContent = Schema.Union([ + TextContent, + ImageContent, + EmbeddedResource +]) + +export const SamplingContent = Schema.Union([ + TextContent, + ImageContent +]) + +export const Resource = Schema.Struct({ + uri: Schema.String, + name: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), + size: optional(Schema.Finite), + annotations: optional(Annotation) +}) + +export const ResourceTemplate = Schema.Struct({ + uriTemplate: Schema.String, + name: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), + annotations: optional(Annotation) +}) + +export const PromptArgument = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + required: optional(Schema.Boolean) +}) + +export const Prompt = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + arguments: optional(Schema.Array(PromptArgument)) +}) + +export const PromptMessage = Schema.Struct({ + role: Role, + content: PromptOrToolContent +}) + +export const ToolInputSchema = Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, JsonObject)), + required: optional(Schema.Array(Schema.String)) +}) + +export const Tool = Schema.Struct({ + name: Schema.String, + description: optional(Schema.String), + inputSchema: ToolInputSchema +}) + +export const ModelHint = Schema.Struct({ + name: optional(Schema.String) +}) + +export const ModelPreferences = Schema.Struct({ + hints: optional(Schema.Array(ModelHint)), + costPriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + speedPriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + intelligencePriority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const SamplingMessage = Schema.Struct({ + role: Role, + content: SamplingContent +}) + +export const ResourceReference = Schema.Struct({ + type: Schema.Literal("ref/resource"), + uri: Schema.String +}) + +export const PromptReference = Schema.Struct({ + type: Schema.Literal("ref/prompt"), + name: Schema.String +}) + +export const Root = Schema.Struct({ + uri: Schema.String, + name: optional(Schema.String) +}) + +export const InitializeResult = Schema.Struct({ + ...ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Implementation, + instructions: optional(Schema.String) +}) + +export const ListResourcesResult = Schema.Struct({ + ...PaginatedResult.fields, + resources: Schema.Array(Resource) +}) + +export const ListResourceTemplatesResult = Schema.Struct({ + ...PaginatedResult.fields, + resourceTemplates: Schema.Array(ResourceTemplate) +}) + +export const ReadResourceResult = Schema.Struct({ + ...ResultMeta.fields, + contents: Schema.Array(ResourceContents) +}) + +export const ListPromptsResult = Schema.Struct({ + ...PaginatedResult.fields, + prompts: Schema.Array(Prompt) +}) + +export const GetPromptResult = Schema.Struct({ + ...ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export const CallToolResult = Schema.Struct({ + ...ResultMeta.fields, + content: Schema.Array(PromptOrToolContent), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...ResultMeta.fields, + role: Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export const CompleteResult = Schema.Struct({ + ...ResultMeta.fields, + completion: Schema.Struct({ + values: Schema.Array(Schema.String), + total: optional(Schema.Finite), + hasMore: optional(Schema.Boolean) + }) +}) + +export const ListRootsResult = Schema.Struct({ + ...ResultMeta.fields, + roots: Schema.Array(Root) +}) + +export class Ping extends Rpc.make("ping", { + success: ResultMeta, + error: McpError, + payload: Schema.UndefinedOr(RequestMeta) +}) {} + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: McpError, + payload: { + ...RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: ClientCapabilities, + clientInfo: Implementation + } +}) {} + +export class Complete extends Rpc.make("completion/complete", { + success: CompleteResult, + error: McpError, + payload: { + ...RequestMeta.fields, + ref: Schema.Union([PromptReference, ResourceReference]), + argument: Schema.Struct({ + name: Schema.String, + value: Schema.String + }) + } +}) {} + +export class SetLevel extends Rpc.make("logging/setLevel", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + level: LoggingLevel + } +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: McpError, + payload: { + ...RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class ReadResource extends Rpc.make("resources/read", { + success: ReadResourceResult, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class Subscribe extends Rpc.make("resources/subscribe", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class Unsubscribe extends Rpc.make("resources/unsubscribe", { + success: ResultMeta, + error: McpError, + payload: { + ...RequestMeta.fields, + uri: Schema.String + } +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: McpError, + payload: { + ...RequestMeta.fields, + name: Schema.String, + arguments: optional(JsonObject) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: McpError, + payload: Schema.UndefinedOr(PaginatedRequest) +}) {} + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: McpError, + payload: { + ...RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(JsonObject) + } +}) {} + +export class ListRoots extends Rpc.make("roots/list", { + success: ListRootsResult, + error: McpError, + payload: Schema.UndefinedOr(RequestMeta) +}) {} + +export class CancelledNotification extends Rpc.make("notifications/cancelled", { + payload: { + ...NotificationMeta.fields, + requestId: RequestId, + reason: optional(Schema.String) + } +}) {} + +export class ProgressNotification extends Rpc.make("notifications/progress", { + payload: { + ...NotificationMeta.fields, + progressToken: ProgressToken, + progress: Schema.Finite, + total: optional(Schema.Finite) + } +}) {} + +export class InitializedNotification extends Rpc.make("notifications/initialized", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class RootsListChangedNotification extends Rpc.make("notifications/roots/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class LoggingMessageNotification extends Rpc.make("notifications/message", { + payload: { + ...NotificationMeta.fields, + level: LoggingLevel, + logger: optional(Schema.String), + data: Schema.Any + } +}) {} + +export class ResourceUpdatedNotification extends Rpc.make("notifications/resources/updated", { + payload: { + ...NotificationMeta.fields, + uri: Schema.String + } +}) {} + +export class ResourceListChangedNotification extends Rpc.make("notifications/resources/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class ToolListChangedNotification extends Rpc.make("notifications/tools/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class PromptListChangedNotification extends Rpc.make("notifications/prompts/list_changed", { + payload: Schema.UndefinedOr(NotificationMeta) +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Ping, + Initialize, + Complete, + SetLevel, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + ReadResource, + Subscribe, + Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + CancelledNotification, + ProgressNotification, + InitializedNotification, + RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Ping, + CreateMessage, + ListRoots +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + CancelledNotification, + ProgressNotification, + LoggingMessageNotification, + ResourceUpdatedNotification, + ResourceListChangedNotification, + ToolListChangedNotification, + PromptListChangedNotification +) {} diff --git a/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts b/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts new file mode 100644 index 00000000000..ee23ed9722c --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_03_26.ts @@ -0,0 +1,204 @@ +/** + * Exact MCP v2025-03-26 wire schemas. + * + * This revision is expressed as a frozen delta from the exact 2024-11-05 + * schemas. Transport envelopes (including JSON-RPC batches) are owned by the + * transport codec rather than Effect RPC payload 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 "./v2024_11_05.ts" + +export * from "./v2024_11_05.ts" + +export const protocolVersion = "2025-03-26" + +const optional = Previous.optional + +export const ServerCapabilities = Schema.Struct({ + ...Previous.ServerCapabilities.fields, + completions: optional(Schema.Struct({})) +}) + +export const AudioContent = Schema.Struct({ + type: Schema.Literal("audio"), + data: Schema.String, + mimeType: Schema.String, + annotations: optional(Previous.Annotation) +}) + +export const PromptOrToolContent = Schema.Union([ + Previous.TextContent, + Previous.ImageContent, + AudioContent, + Previous.EmbeddedResource +]) + +export const SamplingContent = Schema.Union([ + Previous.TextContent, + Previous.ImageContent, + AudioContent +]) + +export const PromptMessage = Schema.Struct({ + role: Previous.Role, + content: PromptOrToolContent +}) + +export const SamplingMessage = Schema.Struct({ + role: Previous.Role, + content: SamplingContent +}) + +export const ToolAnnotations = Schema.Struct({ + title: optional(Schema.String), + readOnlyHint: optional(Schema.Boolean), + destructiveHint: optional(Schema.Boolean), + idempotentHint: optional(Schema.Boolean), + openWorldHint: optional(Schema.Boolean) +}) + +export const Tool = Schema.Struct({ + ...Previous.Tool.fields, + annotations: optional(ToolAnnotations) +}) + +export const InitializeResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Previous.Implementation, + instructions: optional(Schema.String) +}) + +export const GetPromptResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export const CallToolResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + content: Schema.Array(PromptOrToolContent), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + role: Previous.Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: Previous.ClientCapabilities, + clientInfo: Previous.Implementation + } +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.Json)) + } +}) {} + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(Previous.ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(Schema.Record(Schema.String, Schema.Json)) + } +}) {} + +export class ProgressNotification extends Rpc.make("notifications/progress", { + payload: { + ...Previous.NotificationMeta.fields, + progressToken: Previous.ProgressToken, + progress: Schema.Finite, + total: optional(Schema.Finite), + message: optional(Schema.String) + } +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Previous.Ping, + Initialize, + Previous.Complete, + Previous.SetLevel, + GetPrompt, + Previous.ListPrompts, + Previous.ListResources, + Previous.ListResourceTemplates, + Previous.ReadResource, + Previous.Subscribe, + Previous.Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + ProgressNotification, + Previous.InitializedNotification, + Previous.RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Previous.Ping, + CreateMessage, + Previous.ListRoots +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + ProgressNotification, + Previous.LoggingMessageNotification, + Previous.ResourceUpdatedNotification, + Previous.ResourceListChangedNotification, + Previous.ToolListChangedNotification, + Previous.PromptListChangedNotification +) {} diff --git a/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts b/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts new file mode 100644 index 00000000000..1d064bc2a5d --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpSchema/v2025_06_18.ts @@ -0,0 +1,404 @@ +/** + * Exact MCP v2025-06-18 wire schemas. + * + * This module is independent of the public compatibility-oriented McpSchema + * surface and is frozen as a dated delta from v2025-03-26. + * + * @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_03_26.ts" + +export * from "./v2025_03_26.ts" + +export const protocolVersion = "2025-06-18" + +const optional = Previous.optional +const JsonObject = Schema.Record(Schema.String, Schema.Json) +const Meta = optional(JsonObject) + +export const Implementation = Schema.Struct({ + name: Schema.String, + title: optional(Schema.String), + version: Schema.String +}) + +export const ClientCapabilities = Schema.Struct({ + ...Previous.ClientCapabilities.fields, + elicitation: optional(Schema.Struct({})) +}) + +export const Annotations = Schema.Struct({ + audience: optional(Schema.Array(Previous.Role)), + priority: optional(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 }))) +}) + +export const Resource = Schema.Struct({ + ...Previous.Resource.fields, + title: optional(Schema.String), + annotations: optional(Annotations), + _meta: Meta +}) + +export const ResourceTemplate = Schema.Struct({ + ...Previous.ResourceTemplate.fields, + title: optional(Schema.String), + annotations: optional(Annotations), + _meta: Meta +}) + +export const TextResourceContents = Schema.Struct({ + ...Previous.TextResourceContents.fields, + _meta: Meta +}) + +export const BlobResourceContents = Schema.Struct({ + ...Previous.BlobResourceContents.fields, + _meta: Meta +}) + +export const ResourceContents = Schema.Union([TextResourceContents, BlobResourceContents]) + +export const PromptArgument = Schema.Struct({ + ...Previous.PromptArgument.fields, + title: optional(Schema.String) +}) + +export const Prompt = Schema.Struct({ + ...Previous.Prompt.fields, + title: optional(Schema.String), + arguments: optional(Schema.Array(PromptArgument)), + _meta: Meta +}) + +export const EmbeddedResource = Schema.Struct({ + type: Schema.Literal("resource"), + resource: ResourceContents, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ResourceLink = Schema.Struct({ + ...Resource.fields, + type: Schema.Literal("resource_link") +}) + +export const TextContent = Schema.Struct({ + ...Previous.TextContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ImageContent = Schema.Struct({ + ...Previous.ImageContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const AudioContent = Schema.Struct({ + ...Previous.AudioContent.fields, + annotations: optional(Annotations), + _meta: Meta +}) + +export const ContentBlock = Schema.Union([ + TextContent, + ImageContent, + AudioContent, + EmbeddedResource, + ResourceLink +]) + +export const SamplingContent = Schema.Union([TextContent, ImageContent, AudioContent]) + +export const PromptMessage = Schema.Struct({ + role: Previous.Role, + content: ContentBlock +}) + +export const SamplingMessage = Schema.Struct({ + role: Previous.Role, + content: SamplingContent +}) + +export const ServerCapabilities = Schema.Struct({ + ...Previous.ServerCapabilities.fields +}) + +export const InitializeResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + protocolVersion: Schema.String, + capabilities: ServerCapabilities, + serverInfo: Implementation, + instructions: optional(Schema.String) +}) + +export class Initialize extends Rpc.make("initialize", { + success: InitializeResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + protocolVersion: Schema.String, + capabilities: ClientCapabilities, + clientInfo: Implementation + } +}) {} + +const ToolJsonSchema = Schema.StructWithRest( + Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, JsonObject)), + required: optional(Schema.Array(Schema.String)) + }), + [Schema.Record(Schema.String, Schema.Json)] +) + +export const Tool = Schema.Struct({ + name: Schema.String, + title: optional(Schema.String), + description: optional(Schema.String), + inputSchema: ToolJsonSchema, + outputSchema: optional(ToolJsonSchema), + annotations: optional(Previous.ToolAnnotations), + _meta: Meta +}) + +export const CallToolResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + content: Schema.Array(ContentBlock), + structuredContent: optional(JsonObject), + isError: optional(Schema.Boolean) +}) + +export const CreateMessageResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + role: Previous.Role, + content: SamplingContent, + model: Schema.String, + stopReason: optional(Schema.String) +}) + +export class CreateMessage extends Rpc.make("sampling/createMessage", { + success: CreateMessageResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + messages: Schema.Array(SamplingMessage), + modelPreferences: optional(Previous.ModelPreferences), + systemPrompt: optional(Schema.String), + includeContext: optional(Schema.Literals(["none", "thisServer", "allServers"])), + temperature: optional(Schema.Finite), + maxTokens: Schema.Finite, + stopSequences: optional(Schema.Array(Schema.String)), + metadata: optional(JsonObject) + } +}) {} + +export const PromptReference = Schema.Struct({ + ...Previous.PromptReference.fields, + title: optional(Schema.String) +}) + +export const ResourceTemplateReference = Schema.Struct({ + type: Schema.Literal("ref/resource"), + uri: Schema.String +}) + +export const CompleteResult = Previous.CompleteResult + +export class Complete extends Rpc.make("completion/complete", { + success: CompleteResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.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 ListResourcesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resources: Schema.Array(Resource) +}) + +export const ListResourceTemplatesResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + resourceTemplates: Schema.Array(ResourceTemplate) +}) + +export const ReadResourceResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + contents: Schema.Array(ResourceContents) +}) + +export const ListPromptsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + prompts: Schema.Array(Prompt) +}) + +export const GetPromptResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + description: optional(Schema.String), + messages: Schema.Array(PromptMessage) +}) + +export const ListToolsResult = Schema.Struct({ + ...Previous.PaginatedResult.fields, + tools: Schema.Array(Tool) +}) + +export class ListResources extends Rpc.make("resources/list", { + success: ListResourcesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ListResourceTemplates extends Rpc.make("resources/templates/list", { + success: ListResourceTemplatesResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class ReadResource extends Rpc.make("resources/read", { + success: ReadResourceResult, + error: Previous.McpError, + payload: { ...Previous.RequestMeta.fields, uri: Schema.String } +}) {} + +export class ListPrompts extends Rpc.make("prompts/list", { + success: ListPromptsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class GetPrompt extends Rpc.make("prompts/get", { + success: GetPromptResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(Schema.Record(Schema.String, Schema.String)) + } +}) {} + +export class ListTools extends Rpc.make("tools/list", { + success: ListToolsResult, + error: Previous.McpError, + payload: Schema.UndefinedOr(Previous.PaginatedRequest) +}) {} + +export class CallTool extends Rpc.make("tools/call", { + success: CallToolResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + name: Schema.String, + arguments: optional(JsonObject) + } +}) {} + +export const ElicitResult = Schema.Struct({ + ...Previous.ResultMeta.fields, + action: Schema.Literals(["accept", "decline", "cancel"]), + content: optional(Schema.Record( + Schema.String, + Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]) + )) +}) + +const StringSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + minLength: optional(Schema.Int), + maxLength: optional(Schema.Int), + format: optional(Schema.Literals(["email", "uri", "date", "date-time"])) +}) +const NumberSchema = Schema.Struct({ + type: Schema.Literals(["number", "integer"]), + title: optional(Schema.String), + description: optional(Schema.String), + minimum: optional(Schema.Finite), + maximum: optional(Schema.Finite) +}) +const BooleanSchema = Schema.Struct({ + type: Schema.Literal("boolean"), + title: optional(Schema.String), + description: optional(Schema.String), + default: optional(Schema.Boolean) +}) +const EnumSchema = Schema.Struct({ + type: Schema.Literal("string"), + title: optional(Schema.String), + description: optional(Schema.String), + enum: Schema.Array(Schema.String), + enumNames: optional(Schema.Array(Schema.String)) +}) +const RequestedSchema = Schema.Struct({ + type: Schema.Literal("object"), + properties: Schema.Record( + Schema.String, + Schema.Union([StringSchema, NumberSchema, BooleanSchema, EnumSchema]) + ), + required: optional(Schema.Array(Schema.String)) +}) + +export class Elicit extends Rpc.make("elicitation/create", { + success: ElicitResult, + error: Previous.McpError, + payload: { + ...Previous.RequestMeta.fields, + message: Schema.String, + requestedSchema: RequestedSchema + } +}) {} + +export class ClientRequestRpcs extends RpcGroup.make( + Previous.Ping, + Initialize, + Complete, + Previous.SetLevel, + GetPrompt, + ListPrompts, + ListResources, + ListResourceTemplates, + ReadResource, + Previous.Subscribe, + Previous.Unsubscribe, + CallTool, + ListTools +) {} + +export class ClientNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.InitializedNotification, + Previous.RootsListChangedNotification +) {} + +export class ClientRpcs extends ClientRequestRpcs.merge(ClientNotificationRpcs) {} + +export class ServerRequestRpcs extends RpcGroup.make( + Previous.Ping, + CreateMessage, + Previous.ListRoots, + Elicit +) {} + +export class ServerNotificationRpcs extends RpcGroup.make( + Previous.CancelledNotification, + Previous.ProgressNotification, + Previous.LoggingMessageNotification, + Previous.ResourceUpdatedNotification, + Previous.ResourceListChangedNotification, + Previous.ToolListChangedNotification, + Previous.PromptListChangedNotification +) {} From 95af254b20624b03ef63386481a1c0fc663a1649 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 1 Aug 2026 07:24:17 +0200 Subject: [PATCH 2/5] feat: add MCP protocol adapters --- packages/effect/src/unstable/ai/McpSchema.ts | 125 ++++- .../src/unstable/ai/internal/mcpCore.ts | 454 ++++++++++++++++++ .../src/unstable/ai/internal/mcpProtocol.ts | 311 +++++++++++- .../ai/internal/mcpProtocol/v2024_11_05.ts | 355 ++++++++++++++ .../ai/internal/mcpProtocol/v2025_03_26.ts | 360 ++++++++++++++ .../ai/internal/mcpProtocol/v2025_06_18.ts | 374 +++++++++++++++ .../ai/internal/mcpProtocolRegistry.ts | 45 +- .../src/unstable/rpc/RpcSerialization.ts | 2 +- 8 files changed, 1986 insertions(+), 40 deletions(-) create mode 100644 packages/effect/src/unstable/ai/internal/mcpCore.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts create mode 100644 packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index f88bcccbf8a..a85dceae32b 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -3,14 +3,17 @@ * * MCP clients and servers use these schemas to describe the JSON-RPC requests, * notifications, results, and errors that can cross the protocol boundary. This - * module focuses on message shapes: it defines the shared protocol data model, - * groups related messages for the RPC layer, and provides helpers for optional - * fields and parameter metadata. Transport and server behavior live in other - * modules. + * This is the stable public compatibility and authoring surface. It is not an + * exact dated wire contract: MCP protocol adapters use frozen schemas under + * `internal/mcpSchema` for decoding and encoding. This module groups the + * current public message model for application authors and provides helpers + * for optional fields and parameter metadata. Transport and server behavior + * live in other modules. * * @since 4.0.0 */ import * as Context from "../../Context.ts" +import * as Data from "../../Data.ts" import * as Effect from "../../Effect.ts" import { constFalse, constTrue } from "../../Function.ts" import * as Option from "../../Option.ts" @@ -19,11 +22,8 @@ import * as Schema from "../../Schema.ts" import * as SchemaGetter from "../../SchemaGetter.ts" import type * as Scope from "../../Scope.ts" import * as Rpc from "../rpc/Rpc.ts" -import type * as RpcClient from "../rpc/RpcClient.ts" -import type { RpcClientError } from "../rpc/RpcClientError.ts" import * as RpcGroup from "../rpc/RpcGroup.ts" import * as RpcMiddleware from "../rpc/RpcMiddleware.ts" -import type * as McpProtocol from "./McpProtocol.ts" /** * Schema type returned by `optionalWithDefault`. @@ -798,7 +798,7 @@ export class ProgressNotification extends Rpc.make("notifications/progress", { * The progress thus far. This should increase every time progress is made, * even if the total is unknown. */ - progress: optional(Schema.Finite), + progress: Schema.Finite, /** * Total number of items to process (or total progress required), if known. */ @@ -959,7 +959,7 @@ export class BlobResourceContents extends Schema.Opaque()( /** * The binary data of the item decoded from a base64-encoded string. */ - blob: Schema.Uint8Array + blob: Schema.Uint8ArrayFromBase64 })) {} /** @@ -1170,7 +1170,8 @@ export class Prompt extends Schema.Class( /** * A list of arguments to use for templating the prompt. */ - arguments: optional(Schema.Array(PromptArgument)) + arguments: optional(Schema.Array(PromptArgument)), + _meta: optional(Schema.Record(Schema.String, Schema.Json)) }) {} /** @@ -1188,7 +1189,8 @@ export class TextContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.Record(Schema.String, Schema.Json)) })) {} /** @@ -1202,7 +1204,7 @@ export class ImageContent extends Schema.Opaque()(Schema.Struct({ /** * The image data. */ - data: Schema.Uint8Array, + data: Schema.Uint8ArrayFromBase64, /** * The MIME type of the image. Different providers may support different * image types. @@ -1211,7 +1213,8 @@ export class ImageContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.Record(Schema.String, Schema.Json)) })) {} /** @@ -1225,7 +1228,7 @@ export class AudioContent extends Schema.Opaque()(Schema.Struct({ /** * The audio data. */ - data: Schema.Uint8Array, + data: Schema.Uint8ArrayFromBase64, /** * The MIME type of the audio. Different providers may support different * audio types. @@ -1234,7 +1237,8 @@ export class AudioContent extends Schema.Opaque()(Schema.Struct({ /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.Record(Schema.String, Schema.Json)) })) {} /** @@ -1254,7 +1258,8 @@ export class EmbeddedResource extends Schema.Opaque()(Schema.S /** * Optional annotations for the client. */ - annotations: optional(Annotations) + annotations: optional(Annotations), + _meta: optional(Schema.Record(Schema.String, Schema.Json)) })) {} /** @@ -1449,6 +1454,26 @@ export class ToolAnnotations extends Schema.Opaque()(Schema.Str openWorldHint: optionalWithDefault(Schema.Boolean, constTrue) })) {} +/** + * Schema for the object-root JSON Schema used by MCP tool inputs and outputs. + * + * **Details** + * + * Property definitions and additional root keywords are constrained to JSON + * values. The open root supports generated keywords such as `$defs`. + * + * @category tools + * @since 4.0.0 + */ +export const ToolJsonSchema = Schema.StructWithRest( + Schema.Struct({ + type: Schema.Literal("object"), + properties: optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Json))), + required: optional(Schema.Array(Schema.String)) + }), + [Schema.Record(Schema.String, Schema.Json)] +) + /** * Schema for the definition of a tool the client can call. * @@ -1472,11 +1497,11 @@ export class Tool extends Schema.Class( /** * A JSON Schema object defining the expected parameters for the tool. */ - inputSchema: Schema.Any, + inputSchema: ToolJsonSchema, /** - * An optional JSON Schema object defining the expected output of the tool. + * An optional JSON Schema object defining the structure of the tool output. */ - outputSchema: optional(Schema.Any), + outputSchema: optional(ToolJsonSchema), /** * Optional additional tool information. */ @@ -1533,7 +1558,10 @@ export class ListTools extends Rpc.make("tools/list", { export class CallToolResult extends Schema.Class("@effect/ai/McpSchema/CallToolResult")({ ...ResultMeta.fields, content: Schema.Array(ContentBlock), - structuredContent: optional(Schema.Any), + /** + * An optional JSON value containing the structured result of the tool call. + */ + structuredContent: optional(Schema.Json), /** * Whether the tool call ended in an error. * @@ -1796,7 +1824,8 @@ export class ModelPreferences extends Schema.Class( export class CreateMessageResult extends Schema.Class( "@effect/ai/McpSchema/CreateMessageResult" )({ - ...SamplingMessage.fields, + role: Role, + content: Schema.Union([TextContent, ImageContent, AudioContent]), /** * The name of the model that generated the message. */ @@ -1898,6 +1927,7 @@ export class PromptReference extends Schema.Opaque()(Schema.Str * @since 4.0.0 */ export class CompleteResult extends Schema.Opaque()(Schema.Struct({ + ...ResultMeta.fields, completion: Schema.Struct({ /** * An array of completion values. Must not exceed 100 items. @@ -2162,23 +2192,68 @@ export class ElicitationDeclined extends Schema.Error("@eff // McpServerClient // ============================================================================= +/** + * Raised when the negotiated MCP revision or client capabilities do not + * support a server-initiated operation. + * + * @category errors + * @since 4.0.0 + */ +export class McpReverseOperationUnsupported extends Data.TaggedError("McpReverseOperationUnsupported")<{ + readonly operation: "roots/list" | "sampling/createMessage" | "elicitation/create" + readonly protocolVersion: "2024-11-05" | "2025-03-26" | "2025-06-18" + readonly reason: string +}> {} + +/** + * A reverse MCP operation failed while being sent or projected through a + * version adapter. + * + * @category errors + * @since 4.0.0 + */ +export class McpReverseOperationError extends Data.TaggedError("McpReverseOperationError")<{ + readonly operation: "roots/list" | "sampling/createMessage" | "elicitation/create" + readonly cause: unknown +}> {} + +/** + * Version-neutral operations that an MCP server may request from its client. + * + * @category client + * @since 4.0.0 + */ +export interface McpReverseClient { + readonly listRoots: ( + request?: typeof ListRoots.payloadSchema.Type + ) => Effect.Effect + readonly createMessage: ( + request: typeof CreateMessage.payloadSchema.Type + ) => Effect.Effect + readonly elicit: ( + request: typeof Elicit.payloadSchema.Type + ) => Effect.Effect +} + /** * Service available while handling an MCP client request. * * **Details** * - * It exposes the current client id, the client's initialize payload, and a - * scoped RPC client for server-initiated requests back to that client. + * It exposes the current client id, normalized initialization data, and a + * scoped version-neutral facade for server-initiated requests. * * @category services * @since 4.0.0 */ export class McpServerClient extends Context.Service, RpcClientError>, + McpReverseClient, never, Scope.Scope > diff --git a/packages/effect/src/unstable/ai/internal/mcpCore.ts b/packages/effect/src/unstable/ai/internal/mcpCore.ts new file mode 100644 index 00000000000..1d23ac2e28b --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpCore.ts @@ -0,0 +1,454 @@ +/** + * Version-neutral MCP server records and semantic operations. + * + * @internal + */ + +import * as Arr from "../../../Array.ts" +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import type * as Schema from "../../../Schema.ts" +import * as McpSchema from "../McpSchema.ts" + +/** @internal */ +export type CanonicalRequestMetadata = NonNullable< + typeof McpSchema.Initialize.payloadSchema.Type["_meta"] +> + +/** @internal */ +export interface NegotiatedProtocolProfile { + // Core decisions receive negotiated facts rather than dated wire requests. + readonly protocolVersion: string + readonly clientCapabilities: McpSchema.ClientCapabilities + readonly clientInfo: McpSchema.Implementation + readonly requestMetadata?: CanonicalRequestMetadata | undefined +} + +// NOTE: Capabilities remain a normalized core model because dated revisions +// advertise different fields and encode capability presence differently. +/** @internal */ +export interface CanonicalServerCapabilities { + readonly experimental?: Readonly> | undefined + readonly logging?: boolean | undefined + readonly completions?: boolean | undefined + readonly prompts?: Readonly<{ readonly listChanged?: boolean | undefined }> | undefined + readonly resources?: + | Readonly<{ + readonly subscribe?: boolean | undefined + readonly listChanged?: boolean | undefined + }> + | undefined + readonly tools?: Readonly<{ readonly listChanged?: boolean | undefined }> | undefined + readonly extensions?: Schema.JsonObject | undefined +} + +/** @internal */ +export interface CanonicalInitializeResult { + readonly capabilities: CanonicalServerCapabilities + readonly serverInfo: Readonly<{ + readonly name: string + readonly version: string + }> + readonly instructions?: string | undefined +} + +/** @internal */ +export interface McpInvocation { + readonly clientId: number + readonly protocol: NegotiatedProtocolProfile + readonly requestContext: McpSchema.McpServerClient["Service"] +} + +// NOTE: McpInvocation is runtime context, not a wire DTO. It combines the +// negotiated profile with the request-scoped service used by handlers. + +/** @internal */ +export class ResourceNotFound extends Data.TaggedError("ResourceNotFound")<{ + readonly uri: string +}> {} + +// NOTE: Core errors preserve semantic failure categories before adapters map +// them to dated MCP error codes and messages. +/** @internal */ +export class ToolNotFound extends Data.TaggedError("ToolNotFound")<{ + readonly name: string +}> {} + +/** @internal */ +export class InvalidToolInput extends Data.TaggedError("InvalidToolInput")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +export class ToolExecutionError extends Data.TaggedError("ToolExecutionError")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +export class ToolResultProjectionError extends Data.TaggedError("ToolResultProjectionError")<{ + readonly name: string + readonly message: string +}> {} + +/** @internal */ +/** @internal */ +export class UnsupportedByProtocol extends Data.TaggedError("UnsupportedByProtocol")<{ + readonly protocolVersion: string + readonly feature: string +}> {} + +/** @internal */ +export type ToolError = + | ToolNotFound + | InvalidToolInput + | ToolExecutionError + | ToolResultProjectionError + +/** @internal */ +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 handle: ( + call: typeof McpSchema.CallTool.payloadSchema.Type, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.CallToolResult, + InvalidToolInput | ToolExecutionError | ToolResultProjectionError, + never + > +} + +/** @internal */ +export interface Tools { + readonly register: ( + registration: ToolRegistration + ) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly call: ( + call: typeof McpSchema.CallTool.payloadSchema.Type, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface ResourceRegistration { + readonly descriptor: McpSchema.Resource + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly read: ( + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface ResourceTemplateRegistration { + readonly descriptor: McpSchema.ResourceTemplate + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly match: (uri: string) => ReadonlyArray | undefined + readonly read: ( + uri: string, + params: ReadonlyArray, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface Resources { + readonly register: (registration: ResourceRegistration) => Effect.Effect + readonly registerTemplate: (registration: ResourceTemplateRegistration) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly listTemplates: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly read: ( + uri: string, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.ReadResourceResult, + ResourceNotFound | McpSchema.InvalidParams | McpSchema.InternalError + > +} + +/** @internal */ +export class PromptNotFound extends Data.TaggedError("PromptNotFound")<{ + readonly name: string +}> {} + +/** @internal */ +export interface PromptRegistration { + readonly descriptor: McpSchema.Prompt + readonly isVisible: (profile: NegotiatedProtocolProfile) => boolean + readonly get: ( + args: Readonly>, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export interface Prompts { + readonly register: (registration: PromptRegistration) => Effect.Effect + readonly list: ( + profile: NegotiatedProtocolProfile + ) => Effect.Effect> + readonly get: ( + name: string, + args: Readonly>, + invocation: McpInvocation + ) => Effect.Effect< + McpSchema.GetPromptResult, + PromptNotFound | McpSchema.InvalidParams | McpSchema.InternalError + > +} + +/** @internal */ +export type CompletionReference = + | { readonly type: "prompt"; readonly name: string; readonly title?: string | undefined } + | { readonly type: "resourceTemplate"; readonly uriTemplate: string } + +// NOTE: Completion requests/results normalize dated reference tags, optional +// context, and nested CompleteResult values. + +/** @internal */ +export interface CompletionRequest { + readonly reference: CompletionReference + readonly argument: Readonly<{ readonly name: string; readonly value: string }> + readonly context?: Readonly<{ readonly arguments?: Readonly> }> | undefined + readonly metadata?: CanonicalRequestMetadata | undefined +} + +/** @internal */ +export interface CompletionResult { + readonly values: ReadonlyArray + readonly total?: number | undefined + readonly hasMore?: boolean | undefined + readonly metadata?: Schema.JsonObject | undefined +} + +/** @internal */ +export interface Completions { + readonly register: ( + key: string, + complete: ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect + ) => Effect.Effect + readonly complete: ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect +} + +/** @internal */ +export type ClientNotification = Data.TaggedEnum<{ + Initialized: {} + Progress: { + readonly progressToken: string | number + readonly progress: number + readonly total?: number | undefined + readonly message?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + RootsChanged: {} +}> + +/** @internal */ +export const ClientNotification = Data.taggedEnum() + +/** @internal */ +export interface Cancellation { + readonly requestId: string | number + readonly reason?: string | undefined + readonly metadata?: Schema.JsonObject | undefined +} + +/** @internal */ +export type ServerNotification = Data.TaggedEnum<{ + Cancelled: { + readonly requestId: string | number + readonly reason?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + Progress: { + readonly progressToken: string | number + readonly progress: number + readonly total?: number | undefined + readonly message?: string | undefined + readonly metadata?: Schema.JsonObject | undefined + } + LoggingMessage: { + readonly level: McpSchema.LoggingLevel + readonly logger?: string | undefined + readonly data: unknown + readonly metadata?: Schema.JsonObject | undefined + } + ResourceUpdated: { readonly uri: string; readonly metadata?: Schema.JsonObject | undefined } + ResourcesChanged: { readonly metadata?: Schema.JsonObject | undefined } + ToolsChanged: { readonly metadata?: Schema.JsonObject | undefined } + PromptsChanged: { readonly metadata?: Schema.JsonObject | undefined } +}> + +/** @internal */ +export const ServerNotification = Data.taggedEnum() + +/** @internal */ +export interface McpCore { + readonly tools: Tools + readonly resources: Resources + readonly prompts: Prompts + readonly completions: Completions + readonly registrationPresence: Effect.Effect<{ + readonly tools: boolean + readonly resources: boolean + readonly prompts: boolean + }> +} + +/** @internal */ +export const make: Effect.Effect = Effect.sync(() => { + const registrations = new Map() + const resourceRegistrations: Array = [] + const resourceTemplateRegistrations: Array = [] + const promptRegistrations = new Map() + const completionRegistrations = new Map< + string, + ( + request: CompletionRequest, + invocation: McpInvocation + ) => Effect.Effect + >() + + const tools: Tools = { + register: (registration) => + Effect.sync(() => { + registrations.set(registration.descriptor.name, registration) + }), + list: (profile) => + Effect.sync(() => { + const descriptors: Array = [] + for (const registration of registrations.values()) { + if (registration.isVisible(profile)) { + descriptors.push(registration.descriptor) + } + } + return descriptors + }), + call: (call, invocation) => + Effect.suspend((): Effect.Effect => { + const registration = registrations.get(call.name) + if (registration === undefined) { + return new ToolNotFound({ name: call.name }) + } + if (!registration.isVisible(invocation.protocol)) { + return new ToolNotFound({ name: call.name }) + } + return registration.handle(call, invocation) + }) + } + + const resources: Resources = { + register: (registration) => + Effect.sync(() => { + const index = resourceRegistrations.findIndex((entry) => entry.descriptor.uri === registration.descriptor.uri) + if (index === -1) { + resourceRegistrations.push(registration) + } else { + resourceRegistrations[index] = registration + } + }), + registerTemplate: (registration) => + Effect.sync(() => { + const index = resourceTemplateRegistrations.findIndex( + (entry) => entry.descriptor.uriTemplate === registration.descriptor.uriTemplate + ) + if (index === -1) { + resourceTemplateRegistrations.push(registration) + } else { + resourceTemplateRegistrations[index] = registration + } + }), + list: (profile) => + Effect.sync(() => + resourceRegistrations + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + listTemplates: (profile) => + Effect.sync(() => + resourceTemplateRegistrations + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + read: Effect.fnUntraced(function*(uri, invocation) { + const resource = resourceRegistrations.find((entry) => entry.descriptor.uri === uri) + if (resource !== undefined && resource.isVisible(invocation.protocol)) { + return yield* resource.read(invocation) + } + for (const template of resourceTemplateRegistrations) { + const params = template.match(uri) + if (params !== undefined && template.isVisible(invocation.protocol)) { + return yield* template.read(uri, params, invocation) + } + } + return yield* new ResourceNotFound({ uri }) + }) + } + + const prompts: Prompts = { + register: (registration) => + Effect.sync(() => { + promptRegistrations.set(registration.descriptor.name, registration) + }), + list: (profile) => + Effect.sync(() => + Array.from(promptRegistrations.values()) + .filter((entry) => entry.isVisible(profile)) + .map((entry) => entry.descriptor) + ), + get: Effect.fnUntraced(function*(name, args, invocation) { + const registration = promptRegistrations.get(name) + if (registration === undefined || !registration.isVisible(invocation.protocol)) { + return yield* new PromptNotFound({ name }) + } + return yield* registration.get(args, invocation) + }) + } + + const completions: Completions = { + register: (key, complete) => + Effect.sync(() => { + completionRegistrations.set(key, complete) + }), + complete: Effect.fnUntraced(function*(request, invocation) { + const key = request.reference.type === "prompt" + ? `prompt/${request.reference.name}/${request.argument.name}` + : `resource/${request.reference.uriTemplate}/${request.argument.name}` + const complete = completionRegistrations.get(key) + if (complete === undefined) { + return yield* new McpSchema.InvalidParams({ message: "Unknown completion reference or argument" }) + } + const result = yield* complete(request, invocation) + const values = Arr.take(result.values, 100) + return { + ...result, + values, + hasMore: result.hasMore === true || values.length < result.values.length + } + }) + } + + const registrationPresence = Effect.sync(() => ({ + tools: registrations.size > 0, + resources: resourceRegistrations.length > 0 || resourceTemplateRegistrations.length > 0, + prompts: promptRegistrations.size > 0 + })) + + return { tools, resources, prompts, completions, registrationPresence } +}) diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index fe4dee03490..a4a14f26314 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts @@ -1,25 +1,253 @@ -import type * as Effect from "../../../Effect.ts" +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import * as Match from "../../../Match.ts" import * as Schema from "../../../Schema.ts" +import type * as Scope from "../../../Scope.ts" +import type * as Headers from "../../http/Headers.ts" import type * as Rpc from "../../rpc/Rpc.ts" +import * as RpcClient from "../../rpc/RpcClient.ts" +import type { RpcClientError } from "../../rpc/RpcClientError.ts" import type * as RpcGroup from "../../rpc/RpcGroup.ts" +import * as PublicMcpSchema from "../McpSchema.ts" +import * as McpCore from "./mcpCore.ts" + +/** @internal */ +export const profileFromClient = ( + request: PublicMcpSchema.McpServerClient["Service"] +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: request.protocolVersion, + clientCapabilities: request.clientCapabilities, + clientInfo: request.clientInfo, + requestMetadata: request.initializePayload._meta +}) + +/** @internal */ +export const invocationFromClient = ( + request: PublicMcpSchema.McpServerClient["Service"] +): McpCore.McpInvocation => ({ + clientId: request.clientId, + protocol: profileFromClient(request), + requestContext: request +}) export interface PayloadCodecs { readonly decode: (input: unknown) => Effect.Effect readonly encode: (input: unknown) => Effect.Effect } +// 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 +// encoding; do not spread this erasure into MCP domain records. + +/** @internal */ +export interface LifecycleRuntime { + readonly initialize: ( + protocolVersion: string, + profile: McpCore.NegotiatedProtocolProfile, + clientId: number + ) => Effect.Effect + 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 clientNotification: ( + notification: McpCore.ClientNotification, + clientId: number, + headers: Headers.Headers + ) => Effect.Effect +} + +/** @internal */ +export class ProtocolError extends Data.TaggedError("ProtocolError")<{ + readonly code: number + readonly message: string + readonly data?: unknown +}> { + static fromTool( + error: McpCore.ToolError | McpCore.UnsupportedByProtocol + ): ProtocolError { + const message = Match.value(error).pipe( + Match.tag("ToolNotFound", (error) => `Tool '${error.name}' not found`), + Match.tag( + "UnsupportedByProtocol", + (error) => `${error.feature} is not supported by MCP ${error.protocolVersion}` + ), + Match.tags({ + InvalidToolInput: (error) => error.message, + ToolExecutionError: (error) => error.message, + ToolResultProjectionError: (error) => error.message + }), + Match.exhaustive + ) + return new ProtocolError({ code: -32602, message }) + } + + static fromFeature(error: unknown): ProtocolError { + if (error instanceof McpCore.PromptNotFound) { + return new ProtocolError({ code: -32602, message: `Prompt '${error.name}' not found` }) + } + if (error instanceof McpCore.ResourceNotFound) { + return new ProtocolError({ code: -32002, message: `Resource '${error.uri}' not found` }) + } + const decoded = Schema.decodeUnknownResult(ProtocolErrorFields)(error) + if (Result.isSuccess(decoded)) { + return new ProtocolError(decoded.success) + } + return new ProtocolError({ code: -32603, message: "MCP feature handler failed" }) + } +} + +const ProtocolErrorFields = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optionalKey(Schema.Unknown) +}) + +/** @internal */ +export const reverseError = ( + operation: PublicMcpSchema.McpReverseOperationError["operation"] +) => +(cause: unknown) => + cause instanceof PublicMcpSchema.McpReverseOperationUnsupported + ? cause + : new PublicMcpSchema.McpReverseOperationError({ operation, cause }) + +/** @internal */ +export const transcode = < + From extends Schema.Constraint, + To extends Schema.Constraint +>( + from: From, + to: To, + input: Schema.Schema.Type +) => + Schema.encodeEffect(from)(input).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(to)) + ) + +/** @internal */ +export interface ProjectedNotification { + readonly tag: string + readonly payload: unknown +} + +/** @internal */ +export const makeNotificationProjector = Effect.fn(function*( + options: { + readonly supportsProgressMessage: boolean + }, + notification: McpCore.ServerNotification +) { + return McpCore.ServerNotification.$match(notification, { + Cancelled: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.CancelledNotification._tag, + payload: PublicMcpSchema.CancelledNotification.payloadSchema.make({ + _meta: notification.metadata, + requestId: notification.requestId, + reason: notification.reason + }) + }), + Progress: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.ProgressNotification._tag, + payload: PublicMcpSchema.ProgressNotification.payloadSchema.make({ + _meta: notification.metadata, + progressToken: notification.progressToken, + progress: notification.progress, + total: notification.total, + message: options.supportsProgressMessage ? notification.message : undefined + }) + }), + LoggingMessage: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.LoggingMessageNotification._tag, + payload: PublicMcpSchema.LoggingMessageNotification.payloadSchema.make({ + _meta: notification.metadata, + level: notification.level, + logger: notification.logger, + data: notification.data + }) + }), + ResourceUpdated: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.ResourceUpdatedNotification._tag, + payload: PublicMcpSchema.ResourceUpdatedNotification.payloadSchema.make({ + _meta: notification.metadata, + uri: notification.uri + }) + }), + ResourcesChanged: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.ResourceListChangedNotification._tag, + payload: PublicMcpSchema.ResourceListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }), + ToolsChanged: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.ToolListChangedNotification._tag, + payload: PublicMcpSchema.ToolListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }), + PromptsChanged: (notification): ProjectedNotification => ({ + tag: PublicMcpSchema.PromptListChangedNotification._tag, + payload: PublicMcpSchema.PromptListChangedNotification.payloadSchema.make({ + _meta: notification.metadata + }) + }) + }) +}) + +/** @internal */ +export interface HandlerInstallationTarget { + readonly install: < + Rpcs extends Rpc.Any, + Handlers extends RpcGroup.HandlersFrom + >( + protocol: { + readonly protocolVersion: string + }, + rpcs: RpcGroup.RpcGroup, + handlers: Handlers + ) => Effect.Effect> +} + /** @internal */ -export interface AnyProtocolAdapter { +export interface ErasedClientRpcGroup { + readonly requests: ReadonlyMap + readonly prefix: (prefix: string) => RpcGroup.RpcGroup +} + +/** @internal */ +export interface AnyProtocolAdapter { readonly protocolVersion: string readonly transport: { readonly acceptsJsonRpcBatches: boolean readonly requiresVersionHeader: boolean } - readonly clientRpcs: RpcGroup.Any + readonly clientRpcs: ErasedClientRpcGroup readonly clientNotificationRpcs: RpcGroup.Any readonly serverRequestRpcs: RpcGroup.Any readonly serverNotificationRpcs: RpcGroup.Any readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PayloadCodecs + readonly installHandlers: ( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime, + target: HandlerInstallationTarget + ) => Effect.Effect + readonly makeReverseClient: ( + profile: McpCore.NegotiatedProtocolProfile + ) => Effect.Effect< + PublicMcpSchema.McpReverseClient, + never, + RpcClient.Protocol | Scope.Scope + > + readonly projectNotification: ( + notification: McpCore.ServerNotification + ) => Effect.Effect + readonly normalizeCancellation: ( + payload: unknown + ) => Effect.Effect } export interface ProtocolAdapter< @@ -27,8 +255,12 @@ export interface ProtocolAdapter< ClientRpcs extends Rpc.Any = Rpc.Any, ClientNotificationRpcs extends ClientRpcs = ClientRpcs, ServerRequestRpcs extends Rpc.Any = Rpc.Any, - ServerNotificationRpcs extends Rpc.Any = Rpc.Any + ServerNotificationRpcs extends Rpc.Any = Rpc.Any, + HandlerRpcs extends Rpc.Any = never, + HandlerRequirements = never > { + // Each adapter owns its dated RPC vocabulary, transport policy, handler + // projection, and wire behavior. readonly protocolVersion: Version readonly transport: { readonly acceptsJsonRpcBatches: boolean @@ -39,6 +271,25 @@ export interface ProtocolAdapter< readonly serverRequestRpcs: RpcGroup.RpcGroup readonly serverNotificationRpcs: RpcGroup.RpcGroup readonly payloadCodecs: (rpc: Rpc.AnyWithProps) => PayloadCodecs + readonly handlerRpcs?: RpcGroup.RpcGroup | undefined + readonly installHandlers: ( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime, + target: HandlerInstallationTarget + ) => Effect.Effect + readonly makeReverseClient: ( + profile: McpCore.NegotiatedProtocolProfile + ) => Effect.Effect< + PublicMcpSchema.McpReverseClient, + never, + RpcClient.Protocol | Scope.Scope + > + readonly projectNotification: ( + notification: McpCore.ServerNotification + ) => Effect.Effect + readonly normalizeCancellation: ( + payload: unknown + ) => Effect.Effect } /** @internal */ @@ -47,7 +298,9 @@ export const make = < ClientRpcs extends Rpc.Any, ClientNotificationRpcs extends ClientRpcs, ServerRequestRpcs extends Rpc.Any, - ServerNotificationRpcs extends Rpc.Any + ServerNotificationRpcs extends Rpc.Any, + HandlerRpcs extends Rpc.Any = never, + Handlers extends RpcGroup.HandlersFrom = RpcGroup.HandlersFrom >(options: { readonly protocolVersion: Version readonly transport: { @@ -58,12 +311,31 @@ export const make = < readonly clientNotificationRpcs: RpcGroup.RpcGroup readonly serverRequestRpcs: RpcGroup.RpcGroup readonly serverNotificationRpcs: RpcGroup.RpcGroup + readonly handlerRpcs?: RpcGroup.RpcGroup | undefined + readonly makeHandlers?: + | (( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime + ) => Handlers) + | undefined + readonly toReverseClient: ( + profile: McpCore.NegotiatedProtocolProfile, + client: RpcClient.RpcClient + ) => PublicMcpSchema.McpReverseClient + readonly projectNotification: ( + notification: McpCore.ServerNotification + ) => Effect.Effect + readonly normalizeCancellation: ( + payload: unknown + ) => Effect.Effect }): ProtocolAdapter< Version, ClientRpcs, ClientNotificationRpcs, ServerRequestRpcs, - ServerNotificationRpcs + ServerNotificationRpcs, + HandlerRpcs, + RpcGroup.HandlersServices > => { const payloadCodecsCache = new WeakMap() const payloadCodecs = (rpc: Rpc.AnyWithProps): PayloadCodecs => { @@ -78,8 +350,33 @@ export const make = < } return codecs } + + const installHandlers = ( + core: McpCore.McpCore, + lifecycle: LifecycleRuntime, + target: HandlerInstallationTarget + ): Effect.Effect> => + options.handlerRpcs === undefined || options.makeHandlers === undefined + ? Effect.void + : target.install(options, options.handlerRpcs, options.makeHandlers(core, lifecycle)) + + const makeReverseClient = ( + profile: McpCore.NegotiatedProtocolProfile + ): Effect.Effect< + PublicMcpSchema.McpReverseClient, + never, + RpcClient.Protocol | Scope.Scope + > => + RpcClient.make(options.serverRequestRpcs, { + spanPrefix: "McpServer/Client" + }).pipe( + Effect.map((client) => options.toReverseClient(profile, client)) + ) + return { ...options, - payloadCodecs + payloadCodecs, + installHandlers, + makeReverseClient } } 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 new file mode 100644 index 00000000000..df1ba9ac7ba --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2024_11_05.ts @@ -0,0 +1,355 @@ +/** @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/v2024_11_05.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectCapabilities = ( + capabilities: McpCore.CanonicalServerCapabilities +): typeof McpSchema.ServerCapabilities.Type => ({ + experimental: capabilities.experimental, + logging: capabilities.logging ? {} : undefined, + prompts: capabilities.prompts, + resources: capabilities.resources, + tools: capabilities.tools +}) + +const projectContent: ( + content: typeof PublicMcpSchema.ContentBlock.Type +) => Effect.Effect< + typeof McpSchema.PromptOrToolContent.Type, + McpCore.UnsupportedByProtocol +> = Effect.fnUntraced(function*(content) { + const projected = Match.value(content).pipe( + Match.when({ type: "text" }, (content) => ({ + ...content, + annotations: content.annotations + })), + Match.when({ type: "image" }, (content) => + McpSchema.ImageContent.make({ + type: "image", + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.text + }, + annotations: content.annotations + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations + }) + }), + Match.when({ type: Match.is("audio", "resource_link") }, (content) => + new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: `${content.type} tool content` + })), + Match.exhaustive + ) + return projected instanceof McpCore.UnsupportedByProtocol + ? yield* projected + : projected +}) + +const projectResourceContents = ( + content: PublicMcpSchema.TextResourceContents | PublicMcpSchema.BlobResourceContents +): typeof McpSchema.ResourceContents.Type => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + blob: Encoding.encodeBase64(content.blob) + } + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: false, + requiresVersionHeader: false + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: projectCapabilities(result.capabilities), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => + McpSchema.ListResourcesResult.make({ + resources: resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + size: resource.size, + annotations: resource.annotations + })) + }) + ) + ), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => + McpSchema.ListResourceTemplatesResult.make({ + resourceTemplates: resourceTemplates.map((template) => ({ + uriTemplate: template.uriTemplate, + name: template.name, + description: template.description, + mimeType: template.mimeType, + annotations: template.annotations + })) + }) + ) + ), + "resources/read": ({ uri }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map(projectResourceContents), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => + McpSchema.ListPromptsResult.make({ + prompts: prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required + })) + })) + }) + ) + ), + "prompts/get": ({ arguments: args, name }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": (completeRequest) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { type: "prompt", name: completeRequest.ref.name } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": () => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + return yield* core.tools.list(McpProtocol.profileFromClient(request)).pipe( + Effect.map((tools) => + McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + }) + ) + }) + ) + ) + }), + "tools/call": (call) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const result = yield* client["roots/list"](wireRequest) + return new PublicMcpSchema.ListRootsResult({ roots: result.roots }) + }).pipe(Effect.mapError(McpProtocol.reverseError("roots/list"))), + createMessage: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }).pipe(Effect.mapError(McpProtocol.reverseError("sampling/createMessage"))), + elicit: () => + Effect.fail(unsupported("elicitation/create", "Elicitation was introduced after this protocol revision")) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: false + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) 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 new file mode 100644 index 00000000000..bc07e5aa5d0 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_03_26.ts @@ -0,0 +1,360 @@ +/** @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/v2025_03_26.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectCapabilities = ( + capabilities: McpCore.CanonicalServerCapabilities +): typeof McpSchema.ServerCapabilities.Type => ({ + experimental: capabilities.experimental, + logging: capabilities.logging ? {} : undefined, + completions: capabilities.completions ? {} : undefined, + prompts: capabilities.prompts, + resources: capabilities.resources, + tools: capabilities.tools +}) + +const projectContent: ( + content: typeof PublicMcpSchema.ContentBlock.Type +) => Effect.Effect< + typeof McpSchema.PromptOrToolContent.Type, + McpCore.UnsupportedByProtocol +> = Effect.fnUntraced(function*(content) { + // Projection is one-way into the shapes supported by this dated wire schema. + const projected = Match.value(content).pipe( + Match.when({ type: "text" }, (content) => ({ + ...content, + annotations: content.annotations + })), + Match.when({ type: Match.is("image", "audio") }, (content) => ({ + type: content.type, + mimeType: content.mimeType, + data: Encoding.encodeBase64(content.data), + annotations: content.annotations + })), + Match.when({ type: "resource" }, (content) => { + const resource = content.resource + if ("text" in resource) { + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.text + }, + annotations: content.annotations + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations + }) + }), + Match.when({ type: "resource_link" }, () => + new McpCore.UnsupportedByProtocol({ + protocolVersion: McpSchema.protocolVersion, + feature: "resource_link tool content" + })), + Match.exhaustive + ) + return projected instanceof McpCore.UnsupportedByProtocol + ? yield* projected + : projected +}) + +const projectResourceContents = ( + content: PublicMcpSchema.TextResourceContents | PublicMcpSchema.BlobResourceContents +): typeof McpSchema.ResourceContents.Type => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + blob: Encoding.encodeBase64(content.blob) + } + +/** @internal */ +export const protocol = McpProtocol.make({ + protocolVersion: McpSchema.protocolVersion, + transport: { + acceptsJsonRpcBatches: true, + requiresVersionHeader: false + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: projectCapabilities(result.capabilities), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => + McpSchema.ListResourcesResult.make({ + resources: resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + size: resource.size, + annotations: resource.annotations + })) + }) + ) + ), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => + McpSchema.ListResourceTemplatesResult.make({ + resourceTemplates: resourceTemplates.map((template) => ({ + uriTemplate: template.uriTemplate, + name: template.name, + description: template.description, + mimeType: template.mimeType, + annotations: template.annotations + })) + }) + ) + ), + "resources/read": ({ uri }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map(projectResourceContents), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => + McpSchema.ListPromptsResult.make({ + prompts: prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required + })) + })) + }) + ) + ), + "prompts/get": ({ arguments: args, name }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": (completeRequest) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { type: "prompt", name: completeRequest.ref.name } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": () => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const tools = yield* core.tools.list(McpProtocol.profileFromClient(request)) + return McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.title === undefined && tool.annotations === undefined + ? undefined + : McpSchema.ToolAnnotations.make({ + ...tool.annotations, + title: tool.title + }) + }) + ) + }) + }), + "tools/call": (call) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + const content = yield* Effect.forEach(result.content, projectContent).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + return McpSchema.CallToolResult.make({ + content, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const result = yield* client["roots/list"](wireRequest) + return new PublicMcpSchema.ListRootsResult({ roots: result.roots }) + }).pipe(Effect.mapError(McpProtocol.reverseError("roots/list"))), + createMessage: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }).pipe(Effect.mapError(McpProtocol.reverseError("sampling/createMessage"))), + elicit: () => + Effect.fail(unsupported("elicitation/create", "Elicitation was introduced after this protocol revision")) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) 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 new file mode 100644 index 00000000000..3a86bfecc9f --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/mcpProtocol/v2025_06_18.ts @@ -0,0 +1,374 @@ +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/v2025_06_18.ts" + +const ClientRequestRpcs = McpSchema.ClientRequestRpcs.middleware( + PublicMcpSchema.McpServerClientMiddleware +) + +const ClientRpcs = ClientRequestRpcs.merge(McpSchema.ClientNotificationRpcs) + +const AdapterRpcs = ClientRpcs.omit("ping") + +const profileFromInitialize = ( + initialize: typeof McpSchema.Initialize.payloadSchema.Type +): McpCore.NegotiatedProtocolProfile => ({ + protocolVersion: McpSchema.protocolVersion, + clientCapabilities: PublicMcpSchema.ClientCapabilities.make(initialize.capabilities), + clientInfo: PublicMcpSchema.Implementation.make(initialize.clientInfo), + requestMetadata: initialize._meta +}) + +const unsupported = ( + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + reason: string +) => + new PublicMcpSchema.McpReverseOperationUnsupported({ + operation, + protocolVersion: McpSchema.protocolVersion, + reason + }) + +const requireCapability = ( + profile: McpCore.NegotiatedProtocolProfile, + operation: PublicMcpSchema.McpReverseOperationUnsupported["operation"], + capability: "roots" | "sampling" | "elicitation" +) => + Object.hasOwn(profile.clientCapabilities, capability) && + profile.clientCapabilities[capability] !== undefined + ? Effect.void + : Effect.fail(unsupported(operation, `Client did not advertise the ${capability} capability`)) + +const projectCapabilities = ( + capabilities: McpCore.CanonicalServerCapabilities +): typeof McpSchema.ServerCapabilities.Type => ({ + experimental: capabilities.experimental, + logging: capabilities.logging ? {} : undefined, + completions: capabilities.completions ? {} : undefined, + prompts: capabilities.prompts, + resources: capabilities.resources, + tools: capabilities.tools +}) + +const projectContent: ( + content: typeof PublicMcpSchema.ContentBlock.Type +) => Effect.Effect< + typeof McpSchema.ContentBlock.Type, + McpCore.UnsupportedByProtocol +> = Effect.fnUntraced(function*(content) { + return Match.value(content).pipe( + Match.when({ type: Match.is("text", "resource_link") }, (content) => content), + Match.when({ type: Match.is("image", "audio") }, (content) => ({ + 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 McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + text: resource.text + }, + annotations: content.annotations, + _meta: content._meta + }) + } + return McpSchema.EmbeddedResource.make({ + type: "resource", + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + _meta: resource._meta, + blob: Encoding.encodeBase64(resource.blob) + }, + annotations: content.annotations, + _meta: content._meta + }) + }), + Match.exhaustive + ) +}) + +const projectResourceContents = ( + content: PublicMcpSchema.TextResourceContents | PublicMcpSchema.BlobResourceContents +): typeof McpSchema.ResourceContents.Type => + "text" in content + ? { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + text: content.text + } + : { + uri: content.uri, + mimeType: content.mimeType, + _meta: content._meta, + blob: Encoding.encodeBase64(content.blob) + } + +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" + }) +}) + +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, + transport: { + acceptsJsonRpcBatches: false, + requiresVersionHeader: true + }, + clientRpcs: ClientRpcs, + clientNotificationRpcs: McpSchema.ClientNotificationRpcs, + serverRequestRpcs: McpSchema.ServerRequestRpcs, + serverNotificationRpcs: McpSchema.ServerNotificationRpcs, + handlerRpcs: AdapterRpcs, + makeHandlers: (core, lifecycle) => + AdapterRpcs.of({ + initialize: (request, { client }) => + lifecycle.initialize(McpSchema.protocolVersion, profileFromInitialize(request), client.id).pipe( + Effect.map((result) => + McpSchema.InitializeResult.make({ + protocolVersion: McpSchema.protocolVersion, + capabilities: projectCapabilities(result.capabilities), + serverInfo: result.serverInfo, + instructions: result.instructions + }) + ) + ), + "logging/setLevel": ({ level }, { client, headers }) => + lifecycle.setLogLevel(level, client.id, headers).pipe(Effect.as({})), + "notifications/cancelled": () => Effect.void, + "notifications/initialized": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.Initialized(), client.id, headers), + "notifications/progress": (progress, { client, headers }) => + lifecycle.clientNotification( + McpCore.ClientNotification.Progress({ + progressToken: progress.progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + metadata: progress._meta + }), + client.id, + headers + ), + "notifications/roots/list_changed": (_, { client, headers }) => + lifecycle.clientNotification(McpCore.ClientNotification.RootsChanged(), client.id, headers), + "resources/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.resources.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((resources) => McpSchema.ListResourcesResult.make({ resources })) + ), + "resources/templates/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => + core.resources.listTemplates(McpProtocol.profileFromClient(request)) + ).pipe( + Effect.map((resourceTemplates) => McpSchema.ListResourceTemplatesResult.make({ resourceTemplates })) + ), + "resources/read": ({ uri }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.resources.read(uri, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + return McpSchema.ReadResourceResult.make({ + contents: result.contents.map(projectResourceContents), + _meta: result._meta + }) + }), + "resources/subscribe": ({ uri }, { client, headers }) => + lifecycle.subscribe(uri, client.id, headers).pipe(Effect.as({})), + "resources/unsubscribe": ({ uri }, { client, headers }) => + lifecycle.unsubscribe(uri, client.id, headers).pipe(Effect.as({})), + "prompts/list": (_pageRequest) => + PublicMcpSchema.McpServerClient.use((request) => core.prompts.list(McpProtocol.profileFromClient(request))) + .pipe( + Effect.map((prompts) => McpSchema.ListPromptsResult.make({ prompts })) + ), + "prompts/get": ({ arguments: args, name }) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.prompts.get(name, args ?? {}, McpProtocol.invocationFromClient(request)).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromFeature) + ) + const messages = yield* Effect.forEach(result.messages, (message) => + projectContent(message.content).pipe( + Effect.map((content) => ({ role: message.role, content })), + Effect.mapError(McpProtocol.ProtocolError.fromTool) + )) + return McpSchema.GetPromptResult.make({ + description: result.description, + messages, + _meta: result._meta + }) + }), + "completion/complete": (completeRequest) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.completions.complete({ + reference: completeRequest.ref.type === "ref/prompt" + ? { + type: "prompt", + name: completeRequest.ref.name, + title: completeRequest.ref.title + } + : { type: "resourceTemplate", uriTemplate: completeRequest.ref.uri }, + argument: completeRequest.argument, + context: completeRequest.context?.arguments === undefined + ? undefined + : { arguments: completeRequest.context.arguments }, + metadata: completeRequest._meta + }, McpProtocol.invocationFromClient(request)).pipe(Effect.mapError(McpProtocol.ProtocolError.fromFeature)) + return McpSchema.CompleteResult.make({ + completion: { + values: Array.from(result.values), + total: result.total, + hasMore: result.hasMore + }, + _meta: result.metadata + }) + }), + "tools/list": () => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const tools = yield* core.tools.list(McpProtocol.profileFromClient(request)) + return McpSchema.ListToolsResult.make({ + tools: tools.map((tool) => + McpSchema.Tool.make({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations === undefined + ? undefined + : McpSchema.ToolAnnotations.make({ + readOnlyHint: tool.annotations.readOnlyHint, + destructiveHint: tool.annotations.destructiveHint, + idempotentHint: tool.annotations.idempotentHint, + openWorldHint: tool.annotations.openWorldHint + }), + _meta: tool._meta + }) + ) + }) + }), + "tools/call": (call) => + Effect.gen(function*() { + const request = yield* PublicMcpSchema.McpServerClient + const result = yield* core.tools.call( + { ...call, arguments: call.arguments ?? {} }, + McpProtocol.invocationFromClient(request) + ).pipe( + Effect.mapError(McpProtocol.ProtocolError.fromTool) + ) + 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) + ) + return McpSchema.CallToolResult.make({ + content, + structuredContent, + isError: result.isError, + _meta: result._meta + }) + }) + }), + toReverseClient: (profile, client) => ({ + listRoots: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "roots/list", "roots") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.ListRoots.payloadSchema, + McpSchema.ListRoots.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("roots/list", "Request is not representable by this protocol")) + ) + const result = yield* client["roots/list"](wireRequest) + return new PublicMcpSchema.ListRootsResult({ roots: result.roots }) + }).pipe(Effect.mapError(McpProtocol.reverseError("roots/list"))), + createMessage: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "sampling/createMessage", "sampling") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.CreateMessage.payloadSchema, + McpSchema.CreateMessage.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("sampling/createMessage", "Request is not representable by this protocol")) + ) + const result = yield* client["sampling/createMessage"](wireRequest) + return yield* McpProtocol.transcode( + McpSchema.CreateMessage.successSchema, + PublicMcpSchema.CreateMessage.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("sampling/createMessage", "Response is not representable by the canonical model") + ) + ) + }).pipe(Effect.mapError(McpProtocol.reverseError("sampling/createMessage"))), + elicit: (request) => + Effect.gen(function*() { + yield* requireCapability(profile, "elicitation/create", "elicitation") + const wireRequest = yield* McpProtocol.transcode( + PublicMcpSchema.Elicit.payloadSchema, + McpSchema.Elicit.payloadSchema, + request + ).pipe( + Effect.mapError(() => unsupported("elicitation/create", "Request is not representable by this protocol")) + ) + const result = yield* client["elicitation/create"](wireRequest) + return yield* McpProtocol.transcode( + McpSchema.Elicit.successSchema, + PublicMcpSchema.Elicit.successSchema, + result + ).pipe( + Effect.mapError(() => + unsupported("elicitation/create", "Response is not representable by the canonical model") + ) + ) + }).pipe(Effect.mapError(McpProtocol.reverseError("elicitation/create"))) + }), + projectNotification: (notification) => + McpProtocol.makeNotificationProjector({ + supportsProgressMessage: true + }, notification), + normalizeCancellation: (payload) => + Schema.decodeUnknownEffect(McpSchema.CancelledNotification.payloadSchema)(payload).pipe( + Effect.map((request) => ({ + requestId: request.requestId, + reason: request.reason, + metadata: request._meta + })) + ) +}) diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts b/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts index 818bba60c1f..8880a56b1d8 100644 --- a/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts +++ b/packages/effect/src/unstable/ai/internal/mcpProtocolRegistry.ts @@ -1,16 +1,16 @@ import type { NonEmptyReadonlyArray } from "../../../Array.ts" import * as Cause from "../../../Cause.ts" import * as Effect from "../../../Effect.ts" +import type * as Rpc from "../../rpc/Rpc.ts" import type * as RpcGroup from "../../rpc/RpcGroup.ts" import type * as RpcMessage from "../../rpc/RpcMessage.ts" import type * as McpProtocol from "./mcpProtocol.ts" type AnyRpcGroup = RpcGroup.RpcGroup -const prefix = (protocol: McpProtocol.AnyProtocolAdapter): string => - `@effect/mcp/${encodeURIComponent(protocol.protocolVersion)}/` - -const asRpcGroup = (group: RpcGroup.Any): AnyRpcGroup => group as unknown as AnyRpcGroup +const prefix = (protocol: { + readonly protocolVersion: string +}): string => `@effect/mcp/${encodeURIComponent(protocol.protocolVersion)}/` /** @internal */ export interface ProtocolRegistry< @@ -23,8 +23,14 @@ export interface ProtocolRegistry< protocol: Protocol, request: RpcMessage.RequestEncoded ) => RpcMessage.RequestEncoded + readonly handlerTarget: ( + contextMap: Map + ) => McpProtocol.HandlerInstallationTarget } +// NOTE: Protocol selection and request namespacing happen before an adapter's +// payload codec decodes the request. Canonical McpSchema value reuse must not +// introduce a shared permissive decode-first path. /** @internal */ export const make = Effect.fnUntraced(function*< const Protocols extends NonEmptyReadonlyArray @@ -49,10 +55,9 @@ export const make = Effect.fnUntraced(function*< } byVersion.set(protocol.protocolVersion, protocol) } - - let clientRpcs = asRpcGroup(snapshot[0].clientRpcs).prefix(prefix(snapshot[0])) + let clientRpcs = snapshot[0].clientRpcs.prefix(prefix(snapshot[0])) for (let i = 1; i < snapshot.length; i++) { - clientRpcs = clientRpcs.merge(asRpcGroup(snapshot[i].clientRpcs).prefix(prefix(snapshot[i]))) + clientRpcs = clientRpcs.merge(snapshot[i].clientRpcs.prefix(prefix(snapshot[i]))) } return { @@ -65,6 +70,32 @@ export const make = Effect.fnUntraced(function*< ) => ({ ...request, tag: `${prefix(protocol)}${request.tag}` + }), + handlerTarget: (contextMap: Map): McpProtocol.HandlerInstallationTarget => ({ + install: Effect.fnUntraced(function*< + Rpcs extends Rpc.Any, + Handlers extends RpcGroup.HandlersFrom + >( + protocol: { + readonly protocolVersion: string + }, + rpcs: RpcGroup.RpcGroup, + handlers: Handlers + ) { + const handlerContext = yield* rpcs.toHandlers(handlers) + for (const rpcDefinition of rpcs.requests.values()) { + const namespacedRpc = clientRpcs.requests.get( + `${prefix(protocol)}${rpcDefinition._tag}` + ) + const handler = handlerContext.mapUnsafe.get(rpcDefinition.key) + if (namespacedRpc === undefined || handler === undefined) { + return yield* Effect.die( + `MCP handler registration invariant failed for ${protocol.protocolVersion}/${rpcDefinition._tag}` + ) + } + contextMap.set(namespacedRpc.key, handler) + } + }) }) } satisfies ProtocolRegistry }) diff --git a/packages/effect/src/unstable/rpc/RpcSerialization.ts b/packages/effect/src/unstable/rpc/RpcSerialization.ts index f6aa9c7282f..b81f6a5c042 100644 --- a/packages/effect/src/unstable/rpc/RpcSerialization.ts +++ b/packages/effect/src/unstable/rpc/RpcSerialization.ts @@ -260,7 +260,7 @@ function decodeJsonRpcRaw( for (let i = 0; i < decoded.length; i++) { const message = decodeJsonRpcMessage(decoded[i]) messages.push(message) - if (message._tag === "Request") { + if (message._tag === "Request" && !Predicate.isNullish(decoded[i].id)) { batch.size++ batches.set(message.id, batch) } From 194642ea2f2863df40ddb799322ebce901e396e2 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 1 Aug 2026 07:24:20 +0200 Subject: [PATCH 3/5] feat: support MCP protocol version selection --- .changeset/mcp-protocol-versions.md | 5 + packages/effect/MCP.md | 10 +- .../effect/src/unstable/ai/McpProtocol.ts | 49 +- packages/effect/src/unstable/ai/McpServer.ts | 1460 +++++++++-------- 4 files changed, 840 insertions(+), 684 deletions(-) create mode 100644 .changeset/mcp-protocol-versions.md diff --git a/.changeset/mcp-protocol-versions.md b/.changeset/mcp-protocol-versions.md new file mode 100644 index 00000000000..914851871eb --- /dev/null +++ b/.changeset/mcp-protocol-versions.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +MCP servers now support the 2024-11-05 and 2025-03-26 RPC revisions through version-specific protocol adapters. diff --git a/packages/effect/MCP.md b/packages/effect/MCP.md index 3668e6a9e90..66258490c04 100644 --- a/packages/effect/MCP.md +++ b/packages/effect/MCP.md @@ -80,9 +80,13 @@ The server exposes three main parts: The part layers are merged into one layer that has a MCP server implementation as dependency. `McpServer.layerStdio` is used to create a standard I/O–based MCP server identified by its name and version. Its ordered, non-empty `protocols` declaration names implemented protocol adapters rather -than arbitrary version strings. This release supports `McpProtocol.v2025_06_18`. Because of the -layer architecture the server implementation can be easily exchanged with an HTTP-based implementation -with `McpServer.layerHttp`. Finally, a logging layer is added with +than arbitrary version strings. This release supports `McpProtocol.v2024_11_05`, +`McpProtocol.v2025_03_26`, and `McpProtocol.v2025_06_18`. The `v2024_11_05` adapter implements that +revision's RPC schemas and stdio framing, including its batch policy. It does not implement the +historical two-endpoint HTTP+SSE transport. `McpServer.layerHttp` instead offers the 2024 RPC schema +through the same single-endpoint HTTP compatibility transport used by the 2025 adapters. Because of +the layer architecture the server implementation can be easily exchanged with this HTTP-based +implementation. Finally, a logging layer is added with `Logger.layer([Logger.consolePretty({ stderr: true })])`, ensuring logs are written to `stderr`. This is essential when using stdio, as any output to `stdout` would interfere with the protocol communication. diff --git a/packages/effect/src/unstable/ai/McpProtocol.ts b/packages/effect/src/unstable/ai/McpProtocol.ts index 046f2aa4809..e1a7984807a 100644 --- a/packages/effect/src/unstable/ai/McpProtocol.ts +++ b/packages/effect/src/unstable/ai/McpProtocol.ts @@ -3,9 +3,9 @@ * * @since 4.0.0 */ -import type * as RpcGroup from "../rpc/RpcGroup.ts" -import * as Internal from "./internal/mcpProtocol.ts" -import * as McpSchema from "./McpSchema.ts" +import { protocol as protocol2024_11_05 } from "./internal/mcpProtocol/v2024_11_05.ts" +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" /** * The MCP 2025-06-18 protocol implementation. @@ -13,17 +13,30 @@ import * as McpSchema from "./McpSchema.ts" * @category protocols * @since 4.0.0 */ -export const v2025_06_18: ProtocolAdapter = Internal.make({ - protocolVersion: "2025-06-18", - transport: { - acceptsJsonRpcBatches: false, - requiresVersionHeader: true - }, - clientRpcs: McpSchema.ClientRpcs, - clientNotificationRpcs: McpSchema.ClientNotificationRpcs, - serverRequestRpcs: McpSchema.ServerRequestRpcs, - serverNotificationRpcs: McpSchema.ServerNotificationRpcs -}) +export const v2025_06_18 = protocol2025_06_18 + +/** + * The MCP 2025-03-26 protocol implementation. + * + * @category protocols + * @since 4.0.0 + */ +export const v2025_03_26 = protocol2025_03_26 + +/** + * The MCP 2024-11-05 protocol implementation. + * + * **Details** + * + * It provides the dated schema and stdio behavior. When supplied to + * `McpServer.layerHttp`, the server uses its single-endpoint Streamable HTTP + * compatibility transport; it does not implement the historical two-endpoint + * HTTP+SSE transport. + * + * @category protocols + * @since 4.0.0 + */ +export const v2024_11_05 = protocol2024_11_05 /** * An implemented MCP protocol that can be supplied to `McpServer`. @@ -31,13 +44,7 @@ export const v2025_06_18: ProtocolAdapter = Internal.make({ * @category models * @since 4.0.0 */ -export type ProtocolAdapter = Internal.ProtocolAdapter< - "2025-06-18", - RpcGroup.Rpcs, - RpcGroup.Rpcs, - RpcGroup.Rpcs, - RpcGroup.Rpcs -> +export type ProtocolAdapter = typeof v2024_11_05 | typeof v2025_03_26 | typeof v2025_06_18 /** * The MCP protocol versions implemented by this release. diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 4256efd9678..b12b4ad9375 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -24,12 +24,12 @@ import * as Predicate from "../../Predicate.ts" import * as Queue from "../../Queue.ts" import * as RcMap from "../../RcMap.ts" import { CurrentLogLevel } from "../../References.ts" +import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaAST from "../../SchemaAST.ts" import * as Sink from "../../Sink.ts" import type { Stdio } from "../../Stdio.ts" import * as Stream from "../../Stream.ts" -import type * as Types from "../../Types.ts" import * as FindMyWay from "../http/FindMyWay.ts" import * as Headers from "../http/Headers.ts" import { appendPreResponseHandlerUnsafe } from "../http/HttpEffect.ts" @@ -38,43 +38,38 @@ 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 type * as RpcGroup from "../rpc/RpcGroup.ts" +import * 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 type * as McpProtocol from "./McpProtocol.ts" +import * as McpSchema from "./McpSchema.ts" import { CallToolResult, - CancelledNotification, - ClientRpcs, Elicit, ElicitationDeclined, EnabledWhen, GetPromptResult, + Initialize, InternalError, - INVALID_REQUEST_ERROR_CODE, InvalidParams, InvalidRequest, isParam, - ListPromptsResult, - ListResourcesResult, - ListResourceTemplatesResult, - ListToolsResult, - LoggingMessageNotification, - McpErrorBase, McpServerClient, McpServerClientMiddleware, MethodNotFound, - ParseError, + Ping, Prompt, Resource, ResourceTemplate, - ResourceUpdatedNotification, ServerNotificationRpcs, TextContent, - Tool as McpTool + Tool as McpTool, + ToolJsonSchema } from "./McpSchema.ts" import type { CallTool, @@ -82,8 +77,7 @@ import type { Complete, CompleteResult, GetPrompt, - Initialize, - LoggingLevel, + McpErrorBase, Param, PromptArgument, PromptMessage, @@ -95,6 +89,70 @@ import type * as Toolkit from "./Toolkit.ts" type CompletionContext = typeof Complete.payloadSchema.Type["context"] +const internalState = new WeakMap +}>() +type ServerExtensions = NonNullable +type ServerNotificationRequest< + R extends Rpc.Any = RpcGroup.Rpcs +> = R extends Rpc.Any ? RpcMessage.Request : never +const isJson = Schema.is(Schema.Json) + +const validateStructuredContent = ( + toolName: string, + value: unknown +): Effect.Effect => + isJson(value) + ? Effect.succeed(value) + : Effect.fail( + new McpCore.ToolResultProjectionError({ + name: toolName, + message: `Tool '${toolName}' returned structured content that is not valid JSON` + }) + ) + +const toInternalServerNotification = ( + message: ServerNotificationRequest +): McpCore.ServerNotification | undefined => { + switch (message.tag) { + case "notifications/cancelled": + return McpCore.ServerNotification.Cancelled({ + requestId: message.payload.requestId, + reason: message.payload.reason, + metadata: message.payload._meta + }) + case "notifications/progress": + return McpCore.ServerNotification.Progress({ + progressToken: message.payload.progressToken, + progress: message.payload.progress, + total: message.payload.total, + message: message.payload.message, + metadata: message.payload._meta + }) + case "notifications/message": + return McpCore.ServerNotification.LoggingMessage({ + level: message.payload.level, + logger: message.payload.logger, + data: message.payload.data, + metadata: message.payload._meta + }) + case "notifications/resources/updated": + return McpCore.ServerNotification.ResourceUpdated({ + uri: message.payload.uri, + metadata: message.payload._meta + }) + case "notifications/resources/list_changed": + return McpCore.ServerNotification.ResourcesChanged({ metadata: message.payload?._meta }) + case "notifications/tools/list_changed": + return McpCore.ServerNotification.ToolsChanged({ metadata: message.payload?._meta }) + case "notifications/prompts/list_changed": + return McpCore.ServerNotification.PromptsChanged({ metadata: message.payload?._meta }) + default: + return undefined + } +} + /** * Service that stores and serves an MCP server's registered tools, resources, * prompts, completions, and outgoing notifications. @@ -109,9 +167,7 @@ type CompletionContext = typeof Complete.payloadSchema.Type["context"] */ export class McpServer extends Context.Service> - readonly notificationsQueue: Queue.Dequeue> readonly initializedClients: Set - readonly tools: ReadonlyArray<{ readonly tool: McpTool readonly annotations: Context.Context @@ -198,30 +254,11 @@ export class McpServer extends Context.Service - ) => Effect.Effect< - typeof ReadResourceResult.Type, - InternalError | InvalidParams, - McpServerClient - > - } | { - readonly _tag: "Resource" - readonly effect: Effect.Effect - } - >() + const internalCore = yield* McpCore.make const tools = Arr.empty<{ readonly tool: McpTool readonly annotations: Context.Context }>() - const toolMap = new Map< - string, - (payload: any) => Effect.Effect - >() const resources: Array<{ readonly resource: Resource readonly annotations: Context.Context @@ -234,18 +271,7 @@ export class McpServer extends Context.Service }> = [] - const promptMap = new Map< - string, - (params: Record) => Effect.Effect - >() - const completionsMap = new Map< - string, - ( - input: string, - context: CompletionContext - ) => Effect.Effect - >() - const notificationsQueue = yield* Queue.make>() + const notificationsQueue = yield* Queue.make() const listChangedHandles = new Map() const notifications = yield* RpcClient.makeNoSerialization(ServerNotificationRpcs, { spanPrefix: "McpServer/Notifications", @@ -255,18 +281,25 @@ export class McpServer extends Context.Service { - Queue.offerUnsafe(notificationsQueue, message) + Queue.offerUnsafe(notificationsQueue, notification) listChangedHandles.delete(message.tag) }, 0) ) } } else { - Queue.offerUnsafe(notificationsQueue, message) + Queue.offerUnsafe(notificationsQueue, notification) } return notifications.write({ clientId: 0, @@ -277,28 +310,88 @@ export class McpServer extends Context.Service - Effect.suspend(() => { - tools.push(options) - toolMap.set(options.tool.name, options.handle) - return notifications.client["notifications/tools/list_changed"]({}) + Effect.gen(function*() { + const existingIndex = tools.findIndex(({ tool }) => tool.name === options.tool.name) + if (existingIndex === -1) { + tools.push(options) + } else { + tools[existingIndex] = options + } + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + yield* internalCore.tools.register({ + descriptor: new McpTool({ + ...options.tool, + title: options.tool.title ?? options.tool.annotations?.title + }), + isVisible: (profile) => + enabledWhen === undefined || enabledWhen( + { + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + } + ), + handle: (call, invocation) => + options.handle(call.arguments).pipe( + Effect.provideService( + McpServerClient, + invocation.requestContext + ), + Effect.catchTags({ + InternalError: (error) => + Effect.fail( + new McpCore.ToolExecutionError({ + name: options.tool.name, + message: error.message + }) + ), + InvalidParams: (error) => + Effect.fail( + new McpCore.InvalidToolInput({ + name: options.tool.name, + message: error.message + }) + ) + }), + Effect.flatMap((result) => + result.structuredContent === undefined + ? Effect.succeed(result) + : validateStructuredContent(options.tool.name, result.structuredContent).pipe( + Effect.as(result) + ) + ) + ) + }) + yield* notifications.client["notifications/tools/list_changed"]({}) }), callTool: (request) => - Effect.suspend((): Effect.Effect => { - const handle = toolMap.get(request.name) - if (!handle) { - return Effect.fail(new InvalidParams({ message: `Tool '${request.name}' not found` })) - } - return handle(request.arguments).pipe( - Effect.catchDefect(() => Effect.fail(new InternalError({ message: "Internal error" }))) + 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( + Effect.mapError((error) => + new InvalidParams({ + message: error._tag === "ToolNotFound" + ? `Tool '${error.name}' not found` + : error.message + }) + ) ) + return result }), get resources() { return resources @@ -307,74 +400,162 @@ export class McpServer extends Context.Service - Effect.suspend(() => { + Effect.gen(function*() { resources.push(options) - matcher.add(options.resource.uri, { _tag: "Resource", effect: options.handle }) - return notifications.client["notifications/resources/list_changed"]({}) + yield* internalCore.resources.register({ + descriptor: options.resource, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + read: (invocation) => + options.handle.pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) + yield* notifications.client["notifications/resources/list_changed"]({}) }), addResourceTemplate: ({ annotations, completions, handle, routerPath, template }) => - Effect.suspend(() => { + Effect.gen(function*() { resourceTemplates.push({ template, annotations }) - matcher.add(routerPath, { _tag: "ResourceTemplate", handle }) + const templateMatcher = makeUriMatcher() + templateMatcher.add(routerPath, true) + yield* internalCore.resources.registerTemplate({ + descriptor: template, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + match: (uri) => { + const match = templateMatcher.find(uri) + if (match === undefined) { + return undefined + } + const params: Array = [] + for (const key of Object.keys(match.params)) { + params[Number(key)] = match.params[key]! + } + return params + }, + read: (uri, params, invocation) => + handle(uri, Array.from(params)).pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) for (const [param, handle] of Object.entries(completions)) { - completionsMap.set(`ref/resource/${template.uriTemplate}/${param}`, handle) + yield* internalCore.completions.register( + `resource/${template.uriTemplate}/${param}`, + (request) => + handle(request.argument.value, request.context).pipe( + Effect.map((result) => ({ + values: result.completion.values, + total: result.completion.total, + hasMore: result.completion.hasMore, + metadata: result._meta + })) + ) + ) } - return notifications.client["notifications/resources/list_changed"]({}) + yield* notifications.client["notifications/resources/list_changed"]({}) }), findResource: (uri) => - Effect.suspend(() => { - const match = matcher.find(uri) - if (!match) { - return Effect.fail(new McpErrorBase({ code: -32002, message: `Resource '${uri}' not found` })) - } else if (match.handler._tag === "Resource") { - return match.handler.effect - } - const params: Array = [] - for (const key of Object.keys(match.params)) { - params[Number(key)] = match.params[key]! - } - return match.handler.handle(uri, params) + Effect.gen(function*() { + const client = yield* McpServerClient + const result = 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( + Effect.catchTag("ResourceNotFound", () => Effect.succeed({ contents: [] })) + ) + return result }), get prompts() { return prompts }, addPrompt: (options) => - Effect.suspend(() => { + Effect.gen(function*() { prompts.push(options) - promptMap.set(options.prompt.name, options.handle) + yield* internalCore.prompts.register({ + descriptor: options.prompt, + isVisible: (profile) => { + const enabledWhen = Context.getOrUndefined(options.annotations, EnabledWhen) + return enabledWhen === undefined || enabledWhen({ + protocolVersion: profile.protocolVersion, + capabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo + }) + }, + get: (params, invocation) => + options.handle(params).pipe( + Effect.provideService(McpServerClient, invocation.requestContext) + ) + }) for (const [param, handle] of Object.entries(options.completions)) { - completionsMap.set(`ref/prompt/${options.prompt.name}/${param}`, handle) + yield* internalCore.completions.register( + `prompt/${options.prompt.name}/${param}`, + (request, invocation) => + handle(request.argument.value, request.context).pipe( + Effect.provideService( + McpServerClient, + invocation.requestContext + ), + Effect.map((result) => ({ + values: result.completion.values, + total: result.completion.total, + hasMore: result.completion.hasMore, + metadata: result._meta + })) + ) + ) } - return notifications.client["notifications/prompts/list_changed"]({}) + yield* notifications.client["notifications/prompts/list_changed"]({}) }), getPromptResult: Effect.fnUntraced(function*({ arguments: params, name }) { - const handler = promptMap.get(name) - if (!handler) { - return yield* new InvalidParams({ message: `Prompt '${name}' not found` }) - } - return yield* handler(params ?? {}) + const client = yield* McpServerClient + return yield* internalCore.prompts.get( + name, + params ?? {}, + McpProtocolInternal.invocationFromClient(client) + ).pipe( + Effect.catchTag("PromptNotFound", () => new InvalidParams({ message: `Prompt '${name}' not found` })) + ) }), completion: Effect.fnUntraced(function*(complete) { + const client = yield* McpServerClient const ref = complete.ref - const key = ref.type === "ref/resource" - ? `ref/resource/${ref.uri}/${complete.argument.name}` - : `ref/prompt/${ref.name}/${complete.argument.name}` - const handler = completionsMap.get(key) - if (!handler) { - return yield* new InvalidParams({ message: "Unknown completion reference or argument" }) - } - const result = yield* handler(complete.argument.value, complete.context) - const values = Arr.take(result.completion.values, 100) + const result = yield* internalCore.completions.complete({ + reference: ref.type === "ref/resource" + ? { type: "resourceTemplate", uriTemplate: ref.uri } + : { type: "prompt", name: ref.name }, + argument: complete.argument, + context: complete.context + }, McpProtocolInternal.invocationFromClient(client)) return { + _meta: result.metadata, completion: { - ...result.completion, - values, - hasMore: result.completion.hasMore === true || - values.length < result.completion.values.length + values: result.values, + total: result.total, + hasMore: result.hasMore } } }) }) + internalState.set(service, { core: internalCore, notifications: notificationsQueue }) + return service }) /** @@ -388,24 +569,15 @@ export class McpServer extends Context.Service `${typeof requestId}:${requestId}` type SessionLogLevel = - | { - readonly _tag: "Effect" - readonly level: LogLevel.LogLevel - } - | { - readonly _tag: "Mcp" - readonly level: LoggingLevel - } + | { 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 @@ -418,7 +590,7 @@ interface Sessions { class McpClientKey extends Data.Class<{ readonly clientId: number - readonly protocolVersion: string + readonly profile: McpCore.NegotiatedProtocolProfile }> {} class McpProtocolState extends Context.Service ) { + // 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(), @@ -459,7 +634,7 @@ export const run: (options: { readonly name: string readonly version: string readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }) => Effect.Effect< never, Cause.IllegalArgumentError, @@ -468,7 +643,7 @@ export const run: (options: { readonly name: string readonly version: string readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }) { const protocolStateOption = yield* Effect.serviceOption(McpProtocolState) const protocolState = Option.isSome(protocolStateOption) @@ -480,16 +655,18 @@ export const run: (options: { const runWithProtocolState = Effect.fnUntraced(function*(options: { readonly name: string readonly version: string - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }, protocolState: McpProtocolState["Service"]) { - const serverScope = yield* Effect.scope const protocolRegistry = protocolState.protocolRegistry + 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 @@ -497,33 +674,29 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { const clients = yield* RcMap.make({ lookup: Effect.fnUntraced(function*(key: McpClientKey) { - const selectedProtocol = protocolRegistry.select(key.protocolVersion) + const selectedProtocol = protocolRegistry.select(key.profile.protocolVersion) let write!: (message: RpcMessage.FromServerEncoded) => Effect.Effect - const client = yield* RpcClient.make(selectedProtocol.serverRequestRpcs, { - spanPrefix: "McpServer/Client" - }).pipe( - Effect.provideServiceEffect( - RpcClient.Protocol, - RpcClient.Protocol.make(Effect.fnUntraced(function*(writeResponse) { - let cid = 0 - write = (message) => writeResponse(cid, message) - return { - send(id, request, _transferables) { - cid = id - return protocol.send(key.clientId, { - ...request, - headers: undefined, - traceId: undefined, - spanId: undefined, - sampled: undefined - } as any) - }, - supportsAck: true, - supportsTransferables: false, - supportsStructuredClone: false - } - })) - ) + const reverseProtocol = yield* RpcClient.Protocol.make(Effect.fnUntraced(function*(writeResponse) { + let cid = 0 + write = (message) => writeResponse(cid, message) + return { + send(id, request, _transferables) { + cid = id + return protocol.send(key.clientId, { + ...request, + headers: undefined, + traceId: undefined, + spanId: undefined, + sampled: undefined + } as any) + }, + supportsAck: true, + supportsTransferables: false, + supportsStructuredClone: false + } + })) + const client = yield* selectedProtocol.makeReverseClient(key.profile).pipe( + Effect.provideService(RpcClient.Protocol, reverseProtocol) ) return { client, write } as const @@ -551,25 +724,41 @@ 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) - return effect.pipe( + // NOTE: 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 ?? { + protocolVersion: selectedProtocol.protocolVersion, + clientCapabilities: initializePayload.capabilities, + clientInfo: initializePayload.clientInfo + } + clientProfiles.set(client.id, profile) + return Effect.provideService( Effect.provideService( + effect, McpServerClient, McpServerClient.of({ clientId: client.id, - protocolVersion: selectedProtocol.protocolVersion, - initializePayload: session?.initializePayload ?? payload as typeof Initialize.payloadSchema.Type, + protocolVersion: ( + session?.negotiatedProfile.protocolVersion ?? selectedProtocol.protocolVersion + ) as McpProtocol.ProtocolVersion, + clientCapabilities: profile.clientCapabilities, + clientInfo: profile.clientInfo, + initializePayload, getClient: RcMap.get( clients, new McpClientKey({ clientId: client.id, - protocolVersion: selectedProtocol.protocolVersion + profile }) ).pipe( Effect.map(({ client }) => client) ) }) ), - Effect.provideService(CurrentLogLevel, effectLogLevel(session?.logLevel)) + CurrentLogLevel, + effectLogLevel(session?.logLevel, defaultLogLevel) ) }) @@ -586,6 +775,22 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (cancelled === true) { return Effect.void } + if ( + response.exit._tag === "Failure" && + !response.exit.cause.some((failure) => failure._tag === "Fail") + ) { + return protocol.send(clientId, { + _tag: "Exit", + requestId: response.requestId, + exit: { + _tag: "Failure", + cause: [{ + _tag: "Fail", + error: new InternalError({ message: "Internal error" }) + }] + } + }) + } } return protocol.send(clientId, response) }, @@ -624,6 +829,8 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { (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, { @@ -654,7 +861,10 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { } const routedRequest = protocolRegistry.routeClientRequest(selectedProtocol, request) const rpc = protocolRegistry.clientRpcs.requests.get(routedRequest.tag) - if (rpc && selectedProtocol.clientNotificationRpcs.requests.has(request.tag)) { + if ( + rpc && + selectedProtocol.clientNotificationRpcs.requests.has(request.tag) + ) { if (!session) { if (httpRequest) { appendPreResponseHandlerUnsafe( @@ -669,46 +879,44 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { } return Effect.void } - if (request.tag === "notifications/cancelled") { - return decodeCancelledNotification(request.payload).pipe( - Effect.flatMap(({ requestId }) => { - const key = requestKey(requestId) - const requests = activeRequests.get(clientId) - if (requests?.has(key) !== true) { - return Effect.void - } - requests.set(key, true) - return f(clientId, { - _tag: "Interrupt", - requestId: RpcMessage.RequestId(requestId) - }) - }), - Effect.catchCause(() => Effect.void) - ) - } - return selectedProtocol.payloadCodecs(rpc).decode(request.payload).pipe( + 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 + session.initializePayload.capabilities.roots?.listChanged === true && + httpRequest === undefined ) { - if (httpRequest !== undefined) { - return Effect.void - } return RcMap.get( clients, new McpClientKey({ clientId, - protocolVersion: selectedProtocol.protocolVersion + profile: session.negotiatedProfile }) ).pipe( - Effect.flatMap(({ client }) => client["roots/list"](undefined)), + 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, 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, { @@ -765,33 +973,49 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { return f(clientId, request) case "Eof": activeRequests.delete(clientId) + clientProtocols.delete(clientId) + clientProfiles.delete(clientId) + if (!isHttp) { + sessions.byClientId.delete(clientId) + } return f(clientId, request) case "Pong": case "Exit": case "Chunk": case "ClientProtocolError": - case "Defect": + case "Defect": { + const selectedProtocol = getProtocolForClient(clientProtocols, clientId, protocolRegistry) + const profile = clientProfiles.get(clientId) ?? { + protocolVersion: selectedProtocol.protocolVersion, + clientCapabilities: {}, + clientInfo: { name: "unknown", version: "unknown" } + } return RcMap.get( clients, new McpClientKey({ clientId, - protocolVersion: getProtocolForClient(clientProtocols, clientId, protocolRegistry).protocolVersion + profile }) ).pipe( Effect.flatMap(({ write }) => write(request)), Effect.scoped ) + } } }) }) - yield* Queue.take(server.notificationsQueue).pipe( - Effect.flatMap(Effect.fnUntraced(function*(request) { + yield* Queue.take(internalState.get(server)!.notifications).pipe( + Effect.flatMap(Effect.fnUntraced(function*(notification) { const clientIds = yield* patchedProtocol.clientIds for (const clientId of clientProtocols.keys()) { if (!clientIds.has(clientId)) { clientProtocols.delete(clientId) - sessions.byClientId.delete(clientId) + clientProfiles.delete(clientId) + // HTTP client IDs are request-scoped; their UUID sessions outlive them. + if (!isHttp) { + sessions.byClientId.delete(clientId) + } } } for (const clientId of server.initializedClients.keys()) { @@ -803,35 +1027,38 @@ const runWithProtocolState = Effect.fnUntraced(function*(options: { if (!selectedProtocol) { continue } - const rpc = selectedProtocol.serverNotificationRpcs.requests.get(request.tag) - if (!rpc) { - continue - } - if (request.tag === "notifications/message") { - const { level } = yield* Schema.decodeUnknownEffect( - LoggingMessageNotification.payloadSchema - )(request.payload) - if (!isMcpLogLevelEnabled(level, sessions.byClientId.get(clientId)?.logLevel)) { - continue + yield* Effect.gen(function*() { + const projected = yield* selectedProtocol.projectNotification(notification) + if (projected === undefined) { + return } - } - if (request.tag === "notifications/resources/updated") { - const { uri } = yield* Schema.decodeUnknownEffect( - ResourceUpdatedNotification.payloadSchema - )(request.payload) - if (sessions.byClientId.get(clientId)?.resourceSubscriptions?.has(uri) !== true) { - continue + const session = sessions.byClientId.get(clientId) + if ( + notification._tag === "LoggingMessage" && + !isMcpLogLevelEnabled(notification.level, session?.logLevel, defaultLogLevel) + ) { + return } - } - const encoded = yield* selectedProtocol.payloadCodecs(rpc).encode(request.payload) - // TODO: Extend RpcServer.Protocol's outbound message contract with server-originated - // notifications so MCP does not need to treat this notification as an RPC response. - const message: RpcMessage.RequestEncoded = { - _tag: "Request", - tag: request.tag, - payload: encoded - } as any - yield* patchedProtocol.send(clientId, message as any) + if ( + notification._tag === "ResourceUpdated" && + session?.resourceSubscriptions?.has(notification.uri) !== true + ) { + return + } + const rpc = selectedProtocol.serverNotificationRpcs.requests.get(projected.tag) + if (!rpc) { + return + } + const encoded = yield* selectedProtocol.payloadCodecs(rpc).encode(projected.payload) + // TODO: Extend RpcServer.Protocol's outbound message contract with server-originated + // notifications so MCP does not need to treat this notification as an RPC response. + const message: RpcMessage.RequestEncoded = { + _tag: "Request", + tag: projected.tag, + payload: encoded + } as any + yield* patchedProtocol.send(clientId, message as any) + }).pipe(Effect.catchCause(() => Effect.void)) } })), Effect.catchCause(() => Effect.void), @@ -882,7 +1109,7 @@ export const layer = (options: { readonly name: string readonly version: string readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }): Layer.Layer => layerWithProtocolState(options).pipe( Layer.provide(layerMcpProtocolState(options.protocols)) @@ -891,7 +1118,7 @@ export const layer = (options: { const layerWithProtocolState = (options: { readonly name: string readonly version: string - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }): Layer.Layer => Layer.effectDiscard( Effect.gen(function*() { @@ -902,141 +1129,103 @@ const layerWithProtocolState = (options: { Layer.provideMerge(McpServer.layer) ) -const StdioInitializeRequest = Schema.Struct({ - method: Schema.Literal("initialize"), - params: Schema.Struct({ - protocolVersion: Schema.String - }) -}) - -const StdioInvalidBatchExit = Schema.Struct({ - _tag: Schema.Literal("Exit"), - requestId: Schema.Null, - exit: Schema.Struct({ - cause: Schema.Unknown - }) -}) - -const decodeStdioInitializeRequest = Schema.decodeUnknownOption(StdioInitializeRequest) -const decodeStdioInvalidBatchExit = Schema.decodeUnknownOption(StdioInvalidBatchExit) +/** + * Creates a layer that runs an MCP server over standard input and output. + * + * **When to use** + * + * Use when an MCP client launches the server as a subprocess and communicates + * through newline-delimited JSON-RPC messages. + * + * **Details** + * + * The selected protocol adapter controls the dated RPC schemas and JSON-RPC + * batch policy. The layer provides `McpServer` and `McpServerClient` and + * requires `Stdio`. + * + * @see {@link layer} for running over an existing `RpcServer.Protocol` + * @see {@link layerHttp} for the single-endpoint HTTP transport + * + * @category layers + * @since 4.0.0 + */ +export const layerStdio = (options: { + readonly name: string + readonly version: string + readonly protocols: Arr.NonEmptyReadonlyArray + readonly extensions?: ServerExtensions | undefined +}): Layer.Layer => + layer(options).pipe( + Layer.provide(RpcServer.layerProtocolStdio), + Layer.provide(Layer.succeed( + RpcSerialization.RpcSerialization, + mcpStdioSerialization(options.protocols) + )) + ) -const makeStdioSerialization = ( +const mcpStdioSerialization = ( protocols: Arr.NonEmptyReadonlyArray -): RpcSerialization.RpcSerialization["Service"] => - RpcSerialization.RpcSerialization.of({ - contentType: "application/json-rpc", +): RpcSerialization.RpcSerialization["Service"] => { + const serialization = RpcSerialization.jsonRpc({ + contentType: "application/json-rpc" + }) + return RpcSerialization.RpcSerialization.of({ + contentType: serialization.contentType, includesFraming: true, makeUnsafe: () => { - const framing = RpcSerialization.ndjson.makeUnsafe() - const jsonRpc = RpcSerialization.jsonRpc().makeUnsafe() - const protocolsByVersion = new Map( - protocols.map((protocol) => [protocol.protocolVersion, protocol]) - ) - let selectedProtocol = protocols[0] + const frames = RpcSerialization.ndjson.makeUnsafe() + const parser = serialization.makeUnsafe() + let selectedProtocol: McpProtocol.ProtocolAdapter | undefined return { decode: (data) => { - const frames = framing.decode(data) - const messages: Array = [] - for (const frame of frames) { - const entries = Array.isArray(frame) ? frame : [frame] - const initialize = Arr.findFirst(entries, (entry) => decodeStdioInitializeRequest(entry)) - selectedProtocol = Option.match(initialize, { - onNone: () => selectedProtocol, - onSome: ({ params }) => protocolsByVersion.get(params.protocolVersion) ?? protocols[0] - }) - if (Array.isArray(frame) && !selectedProtocol.transport.acceptsJsonRpcBatches) { - messages.push({ - _tag: "Request", - id: null, - tag: MCP_INVALID_BATCH_METHOD, - payload: null, - headers: [] - }) - } else { - messages.push(...jsonRpc.decode(JSON.stringify(frame))) + const decoded: Array = [] + for (const frame of frames.decode(data)) { + if (Array.isArray(frame)) { + const acceptsBatch = selectedProtocol?.transport.acceptsJsonRpcBatches === true + if ( + !acceptsBatch || + frame.length === 0 || + frame.some(isInitializeJsonRpcMessage) + ) { + decoded.push({ + _tag: "Request", + id: null, + tag: MCP_INVALID_BATCH_METHOD, + payload: null, + headers: [] + }) + continue + } + } else if (isInitializeJsonRpcMessage(frame)) { + const offered = getJsonRpcProtocolVersion(frame) + selectedProtocol = protocols.find((protocol) => protocol.protocolVersion === offered) ?? + protocols[0] } + decoded.push(...parser.decode(JSON.stringify(frame))) } - return messages + return decoded }, encode: (response) => { - const invalidBatchExit = decodeStdioInvalidBatchExit(response) - if (Option.isSome(invalidBatchExit)) { - return framing.encode({ + const invalidBatchExit = decodeInvalidBatchExit(response) + if (Result.isSuccess(invalidBatchExit)) { + return JSON.stringify({ jsonrpc: "2.0", id: null, error: { _tag: "Cause", - code: INVALID_REQUEST_ERROR_CODE, + code: McpSchema.INVALID_REQUEST_ERROR_CODE, message: "JSON-RPC batches are not supported", - data: invalidBatchExit.value.exit.cause + data: invalidBatchExit.success.exit.cause } - }) + }) + "\n" } - const encoded = jsonRpc.encode(response) + const encoded = parser.encode(response) return encoded === undefined ? undefined : `${encoded}\n` } } } }) - -/** - * Runs the McpServer, using stdio for input and output. - * - * **Example** (Configuring an MCP server over stdio) - * - * ```ts import.meta.vitest - * import { Effect, Layer, Schema } from "effect" - * import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai" - * - * const idParam = McpSchema.param("id", Schema.Number) - * - * const ReadmeTemplate = McpServer.resource`file://readme/${idParam}`({ - * name: "README Template", - * completion: { - * id: () => Effect.succeed([1, 2, 3]) - * }, - * content: (_uri, id) => Effect.succeed(`# MCP Server Demo - ID: ${id}`) - * }) - * - * const TestPrompt = McpServer.prompt({ - * name: "Test Prompt", - * description: "Looks up flight booking details", - * parameters: { - * flightNumber: Schema.String - * }, - * completion: { - * flightNumber: () => Effect.succeed(["FL123", "FL456"]) - * }, - * content: ({ flightNumber }) => - * Effect.succeed(`Get the booking details for flight number: ${flightNumber}`) - * }) - * - * const ServerLayer = Layer.mergeAll(ReadmeTemplate, TestPrompt).pipe( - * Layer.provide(McpServer.layerStdio({ - * name: "Demo Server", - * version: "1.0.0", - * protocols: [McpProtocol.v2025_06_18] - * })) - * ) - * - * Layer.isLayer(ServerLayer) // => true - * ``` - * - * @category layers - * @since 4.0.0 - */ -export const layerStdio = (options: { - readonly name: string - readonly version: string - readonly protocols: Arr.NonEmptyReadonlyArray - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined -}): Layer.Layer => - layer(options).pipe( - Layer.provide(RpcServer.layerProtocolStdio), - Layer.provide( - Layer.succeed(RpcSerialization.RpcSerialization)(makeStdioSerialization(options.protocols)) - ) - ) +} /** * Registers a Streamable HTTP MCP endpoint at `options.path`. @@ -1049,7 +1238,16 @@ export const layerStdio = (options: { * * POST serves JSON-RPC and accepted notification-only requests return `202`. * Unsupported protocol versions return `400`; methods without MCP handlers - * return `405`. Browser Origins are rejected unless listed in `allowedOrigins`. + * 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 + * to an appropriate interface and installing authentication. + * + * `layerHttp` always implements the single-endpoint Streamable HTTP topology. + * Using `v2024_11_05` here is a custom compatibility transport for that + * revision's schema. It does not implement the historical two-endpoint + * HTTP+SSE transport, GET SSE, event resumption, session expiry, or client + * session termination. * * @see {@link layerStdio} for exposing the server over stdio * @see {@link layer} for the base MCP server layer without a transport protocol @@ -1062,8 +1260,8 @@ export const layerHttp = (options: { readonly version: string readonly path: HttpRouter.PathInput readonly protocols: Arr.NonEmptyReadonlyArray + readonly extensions?: ServerExtensions | undefined readonly allowedOrigins?: ReadonlyArray | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined }): Layer.Layer => { const protocolState = layerMcpProtocolState(options.protocols) const methodNotAllowedResponse = HttpServerResponse.empty({ @@ -1071,12 +1269,9 @@ export const layerHttp = (options: { headers: { allow: "POST" } }) const methodNotAllowed = (request: HttpServerRequest.HttpServerRequest) => - Effect.succeed( - request.headers.origin !== undefined && - !options.allowedOrigins?.includes(request.headers.origin) - ? HttpServerResponse.empty({ status: 403 }) - : methodNotAllowedResponse - ) + isAllowedMcpOrigin(request, options.allowedOrigins) + ? Effect.succeed(methodNotAllowedResponse) + : Effect.succeed(HttpServerResponse.empty({ status: 403 })) const routes = Layer.mergeAll( HttpRouter.add("GET", options.path, methodNotAllowed), HttpRouter.add("PUT", options.path, methodNotAllowed), @@ -1104,146 +1299,148 @@ const layerMcpProtocolHttp = (options: { const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffect const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, (request) => { - if ( - request.headers.origin !== undefined && - !options.allowedOrigins?.includes(request.headers.origin) - ) { + if (!isAllowedMcpOrigin(request, options.allowedOrigins)) { return Effect.succeed(HttpServerResponse.empty({ status: 403 })) } - const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() - if (contentType !== "application/json") { + if (mcpMediaTypes(request.headers["content-type"])[0] !== "application/json") { return Effect.succeed(HttpServerResponse.empty({ status: 415 })) } - const accepted = new Set() - for (const entry of request.headers.accept?.split(",") ?? []) { - const [mediaType, ...parameters] = entry.split(";").map((part) => part.trim().toLowerCase()) - let quality = 1 - for (const parameter of parameters) { - const [name, value] = parameter.split("=", 2).map((part) => part.trim()) - if (name === "q") { - quality = value === undefined ? Number.NaN : Number(value) - } - } - if (mediaType !== undefined && quality > 0 && quality <= 1) { - accepted.add(mediaType) - } - } - if (!accepted.has("application/json") || !accepted.has("text/event-stream")) { + const accepted = mcpMediaTypes(request.headers["accept"]) + 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: () => - Effect.succeed( - HttpServerResponse.jsonUnsafe({ - jsonrpc: "2.0", - id: null, - error: new ParseError({ - message: "Parse error" - }) - }) - ), - onSuccess: (body) => { - return Effect.match(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(body), { - onFailure: () => ({ - _tag: "Error" as const, - id: null, - error: new ParseError({ message: "Parse error" }) - }), + Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: null, + error: new McpSchema.ParseError({ message: "Parse error" }) + })), + onSuccess: (body) => + Effect.matchEffect(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(body), { + onFailure: () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: null, + error: new McpSchema.ParseError({ message: "Parse error" }) + })), onSuccess: (input) => { - if (Array.isArray(input)) { - const sessionId = request.headers[MCP_SESSION_ID_HEADER] - const session = sessionId === undefined ? undefined : state.sessions.bySessionId.get(sessionId) - let selectedProtocol = session?.protocol ?? state.protocolRegistry.protocols[0] - for (const entry of input) { - if ( - Predicate.hasProperty(entry, "method") && - entry.method === "initialize" && - Predicate.hasProperty(entry, "params") && - Predicate.hasProperty(entry.params, "protocolVersion") && - typeof entry.params.protocolVersion === "string" - ) { - selectedProtocol = state.protocolRegistry.select(entry.params.protocolVersion) - break - } + if (!Array.isArray(input)) { + const hasId = Predicate.hasProperty(input, "id") + const id = hasId && (typeof input.id === "string" || typeof input.id === "number") + ? input.id + : null + const isJsonRpc = Predicate.hasProperty(input, "jsonrpc") && input.jsonrpc === "2.0" + const hasValidRequestId = !hasId || typeof input.id === "string" || typeof input.id === "number" + const isRequest = isJsonRpc && hasValidRequestId && + Predicate.hasProperty(input, "method") && typeof input.method === "string" + const hasValidResponseId = hasId && + (typeof input.id === "string" || typeof input.id === "number" || input.id === null) + const hasResult = Predicate.hasProperty(input, "result") + const hasError = Predicate.hasProperty(input, "error") + const isResponse = isJsonRpc && hasValidResponseId && hasResult !== hasError + if (!isRequest && !isResponse) { + return Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id, + error: new InvalidRequest({ message: "Invalid Request" }) + })) } - if (!selectedProtocol.transport.acceptsJsonRpcBatches) { - return { _tag: "HttpError" as const, status: 400 } + const isInitialize = isInitializeJsonRpcMessage(input) + if (isInitialize && sessionId !== undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } - } - const hasId = Predicate.hasProperty(input, "id") - const id = hasId && (typeof input.id === "string" || typeof input.id === "number") - ? input.id - : null - const isJsonRpc = Predicate.hasProperty(input, "jsonrpc") && input.jsonrpc === "2.0" - const hasValidRequestId = hasId === false || typeof input.id === "string" || - typeof input.id === "number" - const isRequest = isJsonRpc && hasValidRequestId && - Predicate.hasProperty(input, "method") && typeof input.method === "string" - const hasValidResponseId = hasId && - (typeof input.id === "string" || typeof input.id === "number" || input.id === null) - const hasResult = Predicate.hasProperty(input, "result") - const hasError = Predicate.hasProperty(input, "error") - const isResponse = isJsonRpc && hasValidResponseId && hasResult !== hasError - const isInitialize = isRequest && input.method === "initialize" - const sessionId = request.headers[MCP_SESSION_ID_HEADER] - const session = sessionId === undefined ? undefined : state.sessions.bySessionId.get(sessionId) - if (isInitialize && sessionId !== undefined) { - return { - _tag: "HttpError" as const, - status: session === undefined ? 404 : 400 + if (!isInitialize && isRequest && sessionId === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } + return httpEffect } - if (!isInitialize && isRequest && session === undefined) { - return { - _tag: "HttpError" as const, - status: sessionId === undefined ? 400 : 404 - } + if (input.length === 0) { + return Effect.succeed(HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: null, + error: new InvalidRequest({ message: "Invalid Request" }) + }, { status: 400 })) } - if ( - session !== undefined && - session.protocol.transport.requiresVersionHeader && - protocolVersion !== session.protocol.protocolVersion - ) { - return { _tag: "HttpError" as const, status: 400 } + if (input.some(isInitializeJsonRpcMessage) || session === undefined) { + return Effect.succeed(HttpServerResponse.empty({ status: 400 })) } - return isRequest || isResponse - ? { _tag: "Success" as const } - : { - _tag: "Error" as const, - id, - error: new InvalidRequest({ message: "Invalid Request" }) - } + const selectedProtocol = session.protocol + return selectedProtocol.transport.acceptsJsonRpcBatches + ? httpEffect + : Effect.succeed(HttpServerResponse.empty({ status: 400 })) } - }).pipe( - Effect.flatMap((decoded) => - decoded._tag === "HttpError" - ? Effect.succeed(HttpServerResponse.empty({ status: decoded.status })) - : decoded._tag === "Error" - ? Effect.succeed( - HttpServerResponse.jsonUnsafe({ - jsonrpc: "2.0", - id: decoded.id, - error: decoded.error - }) - ) - : httpEffect - ) - ) - } + }) }) ) }) return protocol })) +const isAllowedMcpOrigin = ( + request: HttpServerRequest.HttpServerRequest, + allowedOrigins: ReadonlyArray | undefined +): boolean => { + const origin = request.headers["origin"] + return origin === undefined || (allowedOrigins ?? []).includes(origin) +} + +const mcpMediaTypes = (header: string | undefined): ReadonlyArray => + header === undefined + ? [] + : header.split(",").flatMap((part) => { + const [mediaType, ...parameters] = part.split(";") + const quality = parameters + .map((parameter) => parameter.trim().toLowerCase()) + .find((parameter) => parameter.startsWith("q=")) + if (quality !== undefined) { + const value = Number(quality.slice(2)) + if (!Number.isFinite(value) || value <= 0 || value > 1) { + return [] + } + } + return [mediaType.trim().toLowerCase()] + }) + +const InitializeJsonRpcMessage = Schema.Struct({ + method: Schema.Literal("initialize") +}) + +const isInitializeJsonRpcMessage = (message: unknown): boolean => + Result.isSuccess(Schema.decodeUnknownResult(InitializeJsonRpcMessage)(message)) + +const JsonRpcProtocolVersion = Schema.Struct({ + params: Schema.Struct({ + protocolVersion: Schema.String + }) +}) + +const getJsonRpcProtocolVersion = (message: unknown): string | undefined => { + const decoded = Schema.decodeUnknownResult(JsonRpcProtocolVersion)(message) + return Result.isSuccess(decoded) ? decoded.success.params.protocolVersion : undefined +} + const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." const toolErrorResult = (message: string): CallToolResult => @@ -1278,12 +1475,18 @@ export const registerToolkit: >( const annotations = tool.annotations const toolMeta = Context.getOrUndefined(annotations, Tool.Meta) const isDeclaredFailure = Schema.is(tool.failureSchema) - const outputSchema = Tool.getJsonSchemaFromSchema(tool.successSchema) + const outputJsonSchema = Tool.getJsonSchemaFromSchema(tool.successSchema) + const outputSchema = outputJsonSchema.type === "object" + ? yield* Schema.decodeUnknownEffect(ToolJsonSchema)(outputJsonSchema).pipe(Effect.orDie) + : undefined + const inputSchema = yield* Schema.decodeUnknownEffect(ToolJsonSchema)( + Tool.getJsonSchema(tool) + ).pipe(Effect.orDie) const mcpTool = new McpTool({ name: tool.name, description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - ...(outputSchema.type === "object" ? { outputSchema } : {}), + inputSchema, + ...(outputSchema === undefined ? {} : { outputSchema }), annotations: { ...(Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), @@ -1300,7 +1503,7 @@ export const registerToolkit: >( tool: mcpTool, annotations, handle(payload) { - return built.handle(tool.name as keyof Tools, payload).pipe( + return built.handle(tool.name as keyof Tools, payload ?? {}).pipe( Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), @@ -1748,21 +1951,21 @@ export const registerPrompt = < Effect.mapError((error) => new InvalidParams({ message: error.message })), Effect.flatMap((params) => options.content(params as any).pipe( - Effect.map((messages) => { - messages = typeof messages === "string" ? - [{ - role: "user", - content: TextContent.make({ text: messages }) - }] : - messages - return new GetPromptResult({ messages, description: prompt.description }) - }), Effect.catchCause((cause) => { const prettyError = Cause.prettyErrors(cause)[0] return Effect.fail(new InternalError({ message: prettyError.message })) }) ) ), + Effect.map((messages) => { + messages = typeof messages === "string" ? + [{ + role: "user", + content: TextContent.make({ text: messages }) + }] : + messages + return new GetPromptResult({ messages, description: prompt.description }) + }), Effect.provideContext(services as Context.Context) ) }) @@ -1843,7 +2046,7 @@ export const elicit: message: options.message, requestedSchema: Tool.getJsonSchemaFromSchema(schema) }) - const res = yield* client["elicitation/create"](request).pipe( + const res = yield* client.elicit(request).pipe( Effect.catchCause((cause) => Effect.fail(new ElicitationDeclined({ cause: Cause.squash(cause), request }))) ) switch (res.action) { @@ -1866,7 +2069,7 @@ export const clientCapabilities: Effect.Effect< ClientCapabilities, never, McpServerClient -> = McpServerClient.useSync((_) => _.initializePayload.capabilities) +> = McpServerClient.useSync((_) => _.clientCapabilities) // ----------------------------------------------------------------------------- // Internal @@ -1914,10 +2117,11 @@ 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 extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: ServerExtensions | undefined }, options: { readonly sessions: Sessions readonly protocolRegistry: McpProtocolRegistry.ProtocolRegistry @@ -1925,184 +2129,134 @@ const layerHandlers = (serverInfo: { Layer.effectContext( Effect.gen(function*() { const server = yield* McpServer - const currentLogLevel = yield* CurrentLogLevel + 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 selectedProtocol = protocol - const wireHandlers = ClientRpcs.of({ + const wireHandlers = PingRpcs.of({ // Requests - ping: () => Effect.succeed({}), - initialize(params, { client }) { - const capabilities: Types.DeepMutable = { - completions: {}, - logging: {} - } - if (server.tools.length > 0) { - capabilities.tools = { listChanged: true } - } - if (server.resources.length > 0 || server.resourceTemplates.length > 0) { - capabilities.resources = { - listChanged: true, - subscribe: true + ping: () => Effect.succeed({}) + }) + yield* handlerTarget.install(protocol, PingRpcs, wireHandlers) + yield* protocol.installHandlers( + internalCore, + { + initialize: Effect.fnUntraced(function*(protocolVersion, profile, clientId) { + const presence = yield* internalCore.registrationPresence + let capabilities: McpCore.CanonicalServerCapabilities = { + completions: true, + logging: true } - } - if (server.prompts.length > 0) { - capabilities.prompts = { listChanged: true } - } - if (serverInfo.extensions) { - capabilities.extensions = serverInfo.extensions as any - } - return Effect.withFiber((fiber) => { - const httpRequest = Context.getOrUndefined(fiber.context, HttpServerRequest.HttpServerRequest) - if (httpRequest !== undefined && capabilities.resources !== undefined) { - capabilities.resources.subscribe = false + if (presence.tools) { + capabilities = { ...capabilities, tools: { listChanged: true } } } - const session: Session = { - initializePayload: params, - protocol: selectedProtocol, - resourceSubscriptions: capabilities.resources?.subscribe === true ? new Set() : undefined, - logLevel: { _tag: "Effect", level: currentLogLevel } + if (presence.resources) { + capabilities = { + ...capabilities, + resources: { + listChanged: true, + subscribe: true + } + } } - 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]: selectedProtocol.protocolVersion - }))) - } else { - options.sessions.byClientId.set(client.id, session) + if (presence.prompts) { + capabilities = { ...capabilities, prompts: { listChanged: true } } } - return Effect.succeed({ - capabilities, - serverInfo, - protocolVersion: selectedProtocol.protocolVersion - }) - }) - }, - "completion/complete": (r) => - server.completion(r), - "logging/setLevel": ({ level }, { client, headers }) => - Effect.sync(() => { - const session = getClientSession(options.sessions, client.id, headers) - if (session) { - session.logLevel = { _tag: "Mcp", level } + if (serverInfo.extensions) { + capabilities = { + ...capabilities, + extensions: serverInfo.extensions + } } - return {} - }), - "prompts/get": (r) => - server.getPromptResult(r), - "prompts/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListPromptsResult({ prompts: filterByClient(initialized, server.prompts, "prompt") }) - }), - "resources/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListResourcesResult({ resources: filterByClient(initialized, server.resources, "resource") }) - }), - "resources/read": ({ uri }) => server.findResource(uri), - "resources/subscribe": ({ uri }, { client, headers }) => - Effect.gen(function*() { - const subscriptions = getClientSession( - options.sessions, - client.id, - headers - )?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new MethodNotFound({ - message: "Resource subscriptions are not supported" + 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 }) - } - subscriptions.add(uri) - return {} - }), - "resources/unsubscribe": ({ uri }, { client, headers }) => - Effect.gen(function*() { - const subscriptions = getClientSession( - options.sessions, - client.id, - headers - )?.resourceSubscriptions - if (subscriptions === undefined) { - return yield* new MethodNotFound({ - message: "Resource subscriptions are not supported" + 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: { + name: serverInfo.name, + version: serverInfo.version + } }) - } - subscriptions.delete(uri) - return {} - }), - "resources/templates/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListResourceTemplatesResult({ - resourceTemplates: filterByClient(initialized, server.resourceTemplates, "template") }) }), - "tools/call": (r) => server.callTool(r), - "tools/list": (_, { client, headers }) => - Effect.sync(() => { - const initialized = getClientSession(options.sessions, client.id, headers)?.initializePayload - return new ListToolsResult({ - tools: filterByClient(initialized, server.tools, "tool") - }) - }), - - // Notifications - "notifications/cancelled": (_) => Effect.void, - "notifications/initialized": (_, { client, headers }) => - Effect.sync(() => { - server.initializedClients.add(client.id) - const session = getClientSession(options.sessions, client.id, headers) - if (session) { - options.sessions.byClientId.set(client.id, session) - } - }), - "notifications/progress": (_) => Effect.void, - "notifications/roots/list_changed": (_) => Effect.void - }) - yield* addProtocolHandlers( - options.protocolRegistry, - selectedProtocol, - selectedProtocol.clientRpcs, - wireHandlers, - contextMap + setLogLevel: (level, clientId, headers) => + Effect.sync(() => { + const session = getClientSession(options.sessions, clientId, headers) + if (session === undefined) { + return + } + session.logLevel = { _tag: "Mcp", level } + }), + subscribe: (uri, clientId, headers) => + Effect.gen(function*() { + 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: (uri, clientId, headers) => + Effect.gen(function*() { + 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: (notification, clientId) => + notification._tag === "Initialized" + ? Effect.sync(() => { + server.initializedClients.add(clientId) + }) + : Effect.void + }, + handlerTarget ) } return Context.makeUnsafe(contextMap) }) ) -const addProtocolHandlers = Effect.fnUntraced(function*< - ClientRpcs extends Rpc.Any ->( - registry: McpProtocolRegistry.ProtocolRegistry, - protocol: McpProtocol.ProtocolAdapter, - clientRpcs: RpcGroup.RpcGroup, - handlers: RpcGroup.HandlersFrom, - contextMap: Map -) { - const handlerContext = yield* clientRpcs.toHandlers(handlers) - for (const rpcDefinition of clientRpcs.requests.values()) { - const routed = registry.routeClientRequest(protocol, { - _tag: "Request", - id: 0, - tag: rpcDefinition._tag, - payload: undefined, - headers: [] - }) - const namespacedRpc = registry.clientRpcs.requests.get(routed.tag) - const handler = handlerContext.mapUnsafe.get(rpcDefinition.key) - if (namespacedRpc === undefined || handler === undefined) { - return yield* Effect.die(`MCP handler registration invariant failed for ${routed.tag}`) - } - contextMap.set(namespacedRpc.key, handler) - } -}) - const resolveResourceContent = ( uri: string, content: typeof ReadResourceResult.Type | string | Uint8Array @@ -2125,30 +2279,6 @@ const resolveResourceContent = ( return content } -const filterByClient = < - A extends { - readonly annotations: Context.Context - }, - P extends keyof A ->( - client: typeof Initialize.payloadSchema.Type | undefined, - items: ReadonlyArray, - prop: P -): Array => { - if (!client) { - return items.map((item) => item[prop]) - } - const out = Arr.empty() - for (let i = 0; i < items.length; i++) { - const item = items[i] - const enabledWhen = Context.getOrUndefined(item.annotations, EnabledWhen) - if (!enabledWhen || enabledWhen(client)) { - out.push(item[prop]) - } - } - return out -} - const getClientSession = ( sessions: Sessions, clientId: number, @@ -2161,10 +2291,18 @@ const getClientSession = ( return sessions.bySessionId.get(sessionId) } -const mcpLogLevels: Record = { +const InvalidBatchExit = Schema.Struct({ + _tag: Schema.Literal("Exit"), + requestId: Schema.Null, + exit: Schema.Struct({ + _tag: Schema.Literal("Failure"), + cause: Schema.Unknown + }) +}) + +const decodeInvalidBatchExit = Schema.decodeUnknownResult(InvalidBatchExit) + +const mcpLogLevels: Record = { debug: { effect: "Debug", order: 0 }, info: { effect: "Info", order: 1 }, notice: { effect: "Info", order: 2 }, @@ -2175,24 +2313,26 @@ const mcpLogLevels: Record - logLevel?._tag === "Mcp" ? mcpLogLevels[logLevel.level].effect : logLevel?.level ?? "Info" +const effectLogLevel = (logLevel: SessionLogLevel | undefined, fallback: LogLevel.LogLevel): LogLevel.LogLevel => + logLevel?._tag === "Mcp" ? mcpLogLevels[logLevel.level].effect : logLevel?.level ?? fallback const isMcpLogLevelEnabled = ( - level: LoggingLevel, - minimum: SessionLogLevel | undefined + 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 ?? "Info") - -const getOfferedProtocolVersion = (payload: unknown): string => - typeof payload === "object" && - payload !== null && - "protocolVersion" in payload && - typeof payload.protocolVersion === "string" - ? payload.protocolVersion - : "" + : 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, From c5d76e0fd9a3d333141b2f438a88df99f01f3c3f Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 1 Aug 2026 07:21:38 +0200 Subject: [PATCH 4/5] test: add protocol with suite and fix harnesses --- .../src/unstable/ai/internal/mcpProtocol.ts | 1 + .../unstable/ai/McpServer/Lifecycle.test.ts | 254 ---- .../McpConformance/CompletionTest.ts | 44 +- .../McpConformance/ElicitationTest.ts | 19 +- .../McpServer/McpConformance/McpTestPeer.ts | 19 +- .../McpServer/McpConformance/PromptsTest.ts | 27 +- .../ai/McpServer/McpConformance/RootsTest.ts | 13 +- .../McpServer/McpConformance/SamplingTest.ts | 74 +- .../ai/McpServer/McpConformance/ToolsTest.ts | 104 +- .../McpConformance/TransportsTest.ts | 139 +- .../ai/{ => McpServer}/McpProtocol.test.ts | 69 +- .../unstable/ai/McpServer/McpServer.test.ts | 80 +- .../ai/McpServer/ProtocolAdapters.test.ts | 1190 +++++++++++++++++ .../ai/McpServer/TestUtils/McpServerLayer.ts | 4 +- .../test/unstable/ai/McpServer/utils.ts | 64 - .../unstable/ai/McpServer/v2024_11_05.test.ts | 44 + .../unstable/ai/McpServer/v2025_03_26.test.ts | 134 ++ .../unstable/ai/McpServer/v2025_06_18.test.ts | 26 - .../typetest/unstable/ai/McpServer.tst.ts | 8 +- 19 files changed, 1762 insertions(+), 551 deletions(-) delete mode 100644 packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts rename packages/effect/test/unstable/ai/{ => McpServer}/McpProtocol.test.ts (67%) create mode 100644 packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts delete mode 100644 packages/effect/test/unstable/ai/McpServer/utils.ts create mode 100644 packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts create mode 100644 packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts diff --git a/packages/effect/src/unstable/ai/internal/mcpProtocol.ts b/packages/effect/src/unstable/ai/internal/mcpProtocol.ts index a4a14f26314..30e0b99d13d 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 * as Result from "../../../Result.ts" import * as Schema from "../../../Schema.ts" import type * as Scope from "../../../Scope.ts" import type * as Headers from "../../http/Headers.ts" diff --git a/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts b/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts deleted file mode 100644 index 67e4f5857e2..00000000000 --- a/packages/effect/test/unstable/ai/McpServer/Lifecycle.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { assert, describe, it } from "@effect/vitest" -import * as Effect from "effect/Effect" -import * as Layer from "effect/Layer" -import * as Schema from "effect/Schema" -import * as McpSchema from "effect/unstable/ai/McpSchema" -import * as McpServer from "effect/unstable/ai/McpServer" -import * as Tool from "effect/unstable/ai/Tool" -import * as Toolkit from "effect/unstable/ai/Toolkit" -import { makeRawHttpHarness, makeServerLayer } from "./utils.ts" - -const ServerLayer = makeServerLayer({ name: "LifecycleServer" }) -const makeHarness = makeRawHttpHarness(ServerLayer) - -const initializeRequest = (protocolVersion: string, id = 1) => ({ - jsonrpc: "2.0", - id, - method: "initialize", - params: { - protocolVersion, - capabilities: {}, - clientInfo: { - name: "LifecycleClient", - version: "1.0.0" - } - } -}) - -const initializedNotification = { - jsonrpc: "2.0", - method: "notifications/initialized" -} - -const pingRequest = { - jsonrpc: "2.0", - id: 2, - method: "ping", - params: {} -} - -const InitializeResponse = Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.Number, - result: McpSchema.InitializeResult -}) - -const ErrorResponse = Schema.Struct({ - jsonrpc: Schema.Literal("2.0"), - id: Schema.NullOr(Schema.Number), - error: McpSchema.McpError -}) - -const decodeInitializeResponse = Schema.decodeUnknownEffect(InitializeResponse) -const decodeErrorResponse = Schema.decodeUnknownEffect(ErrorResponse) - -type Post = (body: unknown, headers?: HeadersInit) => Effect.Effect - -const initialize = Effect.fnUntraced(function*( - post: Post, - protocolVersion: string, - id = 1 -) { - const response = yield* post(initializeRequest(protocolVersion, id)) - const body = yield* Effect.promise(() => response.json()) - return { - response, - message: yield* decodeInitializeResponse(body) - } as const -}) - -const TestTool = Tool.make("TestTool", { - success: Schema.String -}) -const TestToolkit = Toolkit.make(TestTool) -const TestToolkitLayer = McpServer.toolkit(TestToolkit).pipe( - Layer.provide(TestToolkit.toLayer({ - TestTool: () => Effect.succeed("ok") - })) -) -const FeaturesServerLayer = Layer.mergeAll( - TestToolkitLayer, - McpServer.resource({ - uri: "file:///test", - name: "TestResource", - content: Effect.succeed("test") - }), - McpServer.prompt({ - name: "TestPrompt", - content: () => Effect.succeed("test") - }) -).pipe( - Layer.provide(makeServerLayer({ - name: "LifecycleServer", - extensions: { "example/lifecycle": { enabled: true } } - })) -) - -describe("McpServer initialization", () => { - describe("2025-11-25", () => { - describe("Lifecycle", () => { - describe("1. Lifecycle Phases", () => { - describe("1.1 Initialization", () => { - it.effect("requires initialize to be the first request", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const response = yield* post(pingRequest) - - assert.isAtLeast(response.status, 400) - })) - - it.effect("rejects initialized notifications before initialize", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const response = yield* post(initializedNotification) - - assert.isAtLeast(response.status, 400) - })) - - it.effect("requires protocolVersion, capabilities, and clientInfo", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const invalidParams = [ - { - capabilities: {}, - clientInfo: { name: "LifecycleClient", version: "1.0.0" } - }, - { - protocolVersion: "2025-11-25", - clientInfo: { name: "LifecycleClient", version: "1.0.0" } - }, - { - protocolVersion: "2025-11-25", - capabilities: {} - } - ] - - for (let i = 0; i < invalidParams.length; i++) { - const response = yield* post({ - jsonrpc: "2.0", - id: i + 1, - method: "initialize", - params: invalidParams[i] - }) - const body = yield* Effect.promise(() => response.json()) - const error = yield* decodeErrorResponse(body) - - assert.strictEqual(error.id, i + 1) - assert.isNumber(error.error.code) - assert.isNull(response.headers.get("Mcp-Session-Id")) - } - })) - - it.effect("returns server capabilities and implementation information", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message, response } = yield* initialize(post, "2025-11-25") - - assert.strictEqual(response.status, 200) - assert.strictEqual(message.id, 1) - assert.deepStrictEqual(message.result.capabilities, { - completions: {}, - logging: {} - }) - assert.deepStrictEqual(message.result.serverInfo, { - name: "LifecycleServer", - version: "1.0.0" - }) - const sessionId = response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - assert.match(sessionId, /^[\x21-\x7e]+$/) - })) - - it.effect("accepts initialized after a successful initialize response", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const initialized = yield* initialize(post, "2025-11-25") - const sessionId = initialized.response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - - const response = yield* post(initializedNotification, { - "Mcp-Session-Id": sessionId, - "Mcp-Protocol-Version": initialized.message.result.protocolVersion - }) - - assert.strictEqual(response.status, 202) - assert.strictEqual(yield* Effect.promise(() => response.text()), "") - })) - }) - - describe("1.1.1 Version Negotiation", () => { - it.effect("echoes a requested version supported by the server", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "2025-06-18") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - - it.effect("negotiates an unsupported requested version to the latest supported version", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "2025-11-25") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - }) - - describe("1.1.2 Capability Negotiation", () => { - it.effect("advertises the capabilities provided by the server", () => - Effect.gen(function*() { - const { post } = yield* makeRawHttpHarness(FeaturesServerLayer) - const { message } = yield* initialize(post, "2025-11-25") - - assert.deepStrictEqual(message.result.capabilities, { - completions: {}, - extensions: { "example/lifecycle": { enabled: true } }, - logging: {}, - prompts: { listChanged: true }, - resources: { listChanged: true, subscribe: false }, - tools: { listChanged: true } - }) - })) - }) - - describe("1.2 Operation", () => { - it.effect("continues to use the version negotiated during initialization", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const initialized = yield* initialize(post, "2025-06-18") - const sessionId = initialized.response.headers.get("Mcp-Session-Id") - assert.isNotNull(sessionId) - - const response = yield* post(pingRequest, { - "Mcp-Session-Id": sessionId, - "Mcp-Protocol-Version": initialized.message.result.protocolVersion - }) - - assert.strictEqual(response.status, 200) - assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), "2025-06-18") - })) - }) - }) - - describe("3. Error Handling", () => { - it.effect("handles protocol version mismatch through version negotiation", () => - Effect.gen(function*() { - const { post } = yield* makeHarness - const { message } = yield* initialize(post, "invalid-version") - - assert.strictEqual(message.result.protocolVersion, "2025-06-18") - })) - }) - }) - }) -}) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts index c2eaa2059ee..9218120ca47 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/CompletionTest.ts @@ -47,16 +47,19 @@ const completeRaw = ( export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Completion", () => { - // Shared by the 2024-11-05, 2025-03-26, and 2025-06-18 specifications, - // except completion context, which was added in 2025-06-18. + // Shared by the 2025-03-26 and 2025-06-18 specifications, except + // completion context, which was added in 2025-06-18. describe("Capabilities", () => { - 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") - })) + 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" }) + + assert.property(initialized.message.result.capabilities, "completions") + }) + ) }) describe("Requesting Completions", () => { @@ -79,16 +82,19 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.deepStrictEqual(result.completion.values, ["alpha", "beta"]) })) - it.effect("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.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 diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts index 4addf979628..0bc7ee26c0c 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ElicitationTest.ts @@ -39,7 +39,7 @@ const request = { } as const const runElicitation = , unknown>>( - client: McpTestPeer["client"], + client: McpTestPeer["reverseClient"], protocolVersion: McpProtocol.ProtocolVersion, schema: S ) => @@ -52,6 +52,11 @@ const runElicitation = , RpcClientError.RpcClientError > + readonly reverseClient: McpSchema.McpReverseClient readonly requests: Effect.Effect> readonly takeRequest: Effect.Effect } @@ -41,7 +42,7 @@ const isReverseMethod = (method: string): method is ReverseMethod => ["roots/list", "sampling/createMessage", "elicitation/create"].includes(method) export const makeMcpTestPeer = Effect.fn("McpTestPeer.make")(function*( - _protocol: McpProtocol.ProtocolAdapter, + protocol: McpProtocol.ProtocolAdapter, options: McpTestPeerOptions = {} ) { const requests = yield* Ref.make>([]) @@ -108,12 +109,20 @@ export const makeMcpTestPeer = Effect.fn("McpTestPeer.make")(function*( }) ) - const client = yield* RpcClient.make(McpSchema.ServerRequestRpcs).pipe( + const wireClient = yield* RpcClient.make( + protocol.serverRequestRpcs as unknown as typeof McpSchema.ServerRequestRpcs + ).pipe( Effect.provideService(RpcClient.Protocol, rpcProtocol) ) + const reverseClient = yield* protocol.makeReverseClient({ + protocolVersion: protocol.protocolVersion, + clientCapabilities: options.capabilities ?? {}, + clientInfo: options.clientInfo ?? { name: "McpTestPeer", version: "1.0.0" } + }).pipe(Effect.provideService(RpcClient.Protocol, rpcProtocol)) return { - client, + wireClient, + reverseClient, requests: Ref.get(requests), takeRequest: Queue.take(inbox) } satisfies McpTestPeer diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts index ead7f43245a..a96ce340672 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/PromptsTest.ts @@ -318,18 +318,21 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }]) })) - it.effect("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.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") diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts index a5deec1ca5d..61513494060 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/RootsTest.ts @@ -23,7 +23,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["roots/list"](undefined) + yield* peer.wireClient["roots/list"](undefined) assert.strictEqual((yield* peer.takeRequest).method, "roots/list") })) @@ -38,7 +38,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["roots/list"](undefined) + yield* peer.wireClient["roots/list"](undefined) assert.strictEqual((yield* peer.takeRequest).method, "roots/list") })) @@ -58,7 +58,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["roots/list"](undefined) + const result = yield* peer.wireClient["roots/list"](undefined) assert.deepStrictEqual( result.roots.map((root) => ({ @@ -82,7 +82,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["roots/list"](undefined) + const result = yield* peer.wireClient["roots/list"](undefined) assert.deepStrictEqual(result.roots, []) })) @@ -102,9 +102,10 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const error = yield* peer.client["roots/list"](undefined).pipe(Effect.flip) + const error = yield* peer.wireClient["roots/list"](undefined).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InternalError) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE) assert.strictEqual(error.message, "Roots unavailable") })) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts index 8323da893a2..8baaa628a3f 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/SamplingTest.ts @@ -84,7 +84,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["sampling/createMessage"](samplingRequest) + yield* peer.wireClient["sampling/createMessage"](samplingRequest) assert.strictEqual((yield* peer.takeRequest).method, "sampling/createMessage") })) @@ -101,7 +101,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - yield* peer.client["sampling/createMessage"](samplingRequestWithOptions) + yield* peer.wireClient["sampling/createMessage"](samplingRequestWithOptions) const recorded = yield* peer.takeRequest const payload = yield* decodeSamplingRequest(recorded.payload) @@ -138,7 +138,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( Effect.flatMap(decodeSamplingResult) ) @@ -170,46 +170,49 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( Effect.flatMap(decodeSamplingResult) ) assert.deepStrictEqual(result.content, { type: "image", - data: new Uint8Array([1, 2, 3]), + data: "AQID", mimeType: "image/png" }) })) - it.effect("MUST accept audio sampling content", () => - Effect.gen(function*() { - const test = yield* McpConformance - const peer = yield* test.makePeer({ - capabilities: { sampling: {} }, - handlers: { - "sampling/createMessage": () => - Effect.succeed({ - role: "assistant", - content: { - type: "audio", - data: "BAUG", - mimeType: "audio/wav" - }, - model: "audio-model" - }) - } - }) - - const result = yield* peer.client["sampling/createMessage"](samplingRequest).pipe( - Effect.flatMap(decodeSamplingResult) - ) - - assert.deepStrictEqual(result.content, { - type: "audio", - data: new Uint8Array([4, 5, 6]), - mimeType: "audio/wav" + it.effect.skipIf(["2024-11-05"].includes(protocol.protocolVersion))( + "MUST accept audio sampling content", + () => + Effect.gen(function*() { + const test = yield* McpConformance + const peer = yield* test.makePeer({ + capabilities: { sampling: {} }, + handlers: { + "sampling/createMessage": () => + Effect.succeed({ + role: "assistant", + content: { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }, + model: "audio-model" + }) + } + }) + + const result = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe( + Effect.flatMap(decodeSamplingResult) + ) + + assert.deepStrictEqual(result.content, { + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }) }) - })) + ) it.effect("MUST surface sampling errors returned by the client", () => Effect.gen(function*() { @@ -226,9 +229,10 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman } }) - const error = yield* peer.client["sampling/createMessage"](samplingRequest).pipe(Effect.flip) + const error = yield* peer.wireClient["sampling/createMessage"](samplingRequest).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InternalError) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INTERNAL_ERROR_CODE) assert.strictEqual(error.message, "Sampling failed") })) }) diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts index f50e794b70a..c43916e6375 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/ToolsTest.ts @@ -139,29 +139,32 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman assert.isTrue(result.tools.every((tool) => tool.inputSchema.type === "object")) })) - it.effect("MUST return each declared tool output 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: {} + it.effect.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "MUST return each declared tool output 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 result = yield* test.decodeResult(response).pipe( + 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") }) - 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" - ) - const scalarTool = result.tools.find((tool) => tool.name === "TestTool") - assert.isDefined(scalarTool) - assert.notProperty(scalarTool, "outputSchema") - })) + ) }) describe("Calling Tools", () => { @@ -271,25 +274,31 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman mimeType: "image/png" }]) })) - it.effect("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("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.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") @@ -310,11 +319,14 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman { type: "text", text: "second" } ]) })) - it.effect("SCHEMA returns structured content", () => - Effect.gen(function*() { - const result = yield* callTool("StructuredTool") - assert.deepStrictEqual(result.structuredContent, { value: "structured" }) - })) + 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") diff --git a/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts b/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts index 686df560a9c..6e1930518db 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpConformance/TransportsTest.ts @@ -28,6 +28,11 @@ const jsonRequest = (method: string, body?: unknown, headers?: HeadersInit) => { }) } +const httpTransportSuiteName = (protocol: McpProtocol.ProtocolAdapter) => + protocol.protocolVersion === "2024-11-05" + ? "Single-endpoint HTTP compatibility extension" + : "Streamable HTTP" + export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConformanceLayer) => it.layer(layer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { describe("Transports", () => { @@ -110,28 +115,31 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman }) })) - it.effect("SCENARIO applies the revision-specific stdio batch policy", () => - Effect.gen(function*() { - const fixture = yield* makeMcpStdioHarness(protocol) - yield* fixture.sendRaw({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: protocol.protocolVersion, - capabilities: {}, - clientInfo: { name: "stdio-client", version: "1.0.0" } - } + it.effect.skipIf(["2025-03-26"].includes(protocol.protocolVersion))( + "MUST reject JSON-RPC batches over stdio", + () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: protocol.protocolVersion, + capabilities: {}, + clientInfo: { name: "stdio-client", version: "1.0.0" } + } + }) + yield* fixture.takeFrame + yield* fixture.sendRaw([ + { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, + { jsonrpc: "2.0", id: 3, method: "ping", params: {} } + ]) + const error = yield* fixture.takeFrame.pipe(Effect.flatMap(decodeErrorFrame)) + assert.strictEqual(error.id, null) + assert.strictEqual(error.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) }) - yield* fixture.takeFrame - yield* fixture.sendRaw([ - { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, - { jsonrpc: "2.0", id: 3, method: "ping", params: {} } - ]) - const response = yield* fixture.takeFrame.pipe(Effect.flatMap(decodeErrorFrame)) - assert.strictEqual(response.id, null) - assert.strictEqual(response.error.code, McpSchema.INVALID_REQUEST_ERROR_CODE) - })) + ) it.effect("MUST shut down when the client closes stdin", () => Effect.gen(function*() { @@ -145,7 +153,7 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman })) }) - describe("Streamable HTTP", () => { + describe(httpTransportSuiteName(protocol), () => { describe("Sending Messages to the Server", () => { it.effect("MUST accept JSON-RPC requests through POST on the MCP endpoint", () => Effect.gen(function*() { @@ -341,48 +349,51 @@ export const suite = (protocol: McpProtocol.ProtocolAdapter, layer: McpConforman })) }) - describe("Protocol Version Header", () => { - it.effect("MUST apply the revision-specific protocol header requirement", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: false - }) - assert.strictEqual(response.status, 400) - })) - it.effect("MUST accept the negotiated protocol version", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: true, - protocolVersion: protocol.protocolVersion - }) - assert.strictEqual(response.status, 200) - })) - it.effect("MUST reject an unsupported protocol version with bad request", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - const response = yield* test.ping(initialized, { - includeProtocolVersion: true, - protocolVersion: "2099-01-01" - }) - assert.strictEqual(response.status, 400) - })) - it.effect("SCENARIO replays the selected protocol version on HTTP responses", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - assert.strictEqual( - initialized.response.headers.get("Mcp-Protocol-Version"), - protocol.protocolVersion - ) - const response = yield* test.ping(initialized, { includeProtocolVersion: true }) - assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), protocol.protocolVersion) - })) - }) + describe.skipIf(["2024-11-05", "2025-03-26"].includes(protocol.protocolVersion))( + "Protocol Version Header", + () => { + it.effect("MUST apply the revision-specific protocol header requirement", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: false + }) + assert.strictEqual(response.status, 400) + })) + it.effect("MUST accept the negotiated protocol version", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: true, + protocolVersion: protocol.protocolVersion + }) + assert.strictEqual(response.status, 200) + })) + it.effect("MUST reject an unsupported protocol version with bad request", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + const response = yield* test.ping(initialized, { + includeProtocolVersion: true, + protocolVersion: "2099-01-01" + }) + assert.strictEqual(response.status, 400) + })) + it.effect("SCENARIO replays the selected protocol version on HTTP responses", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.strictEqual( + initialized.response.headers.get("Mcp-Protocol-Version"), + protocol.protocolVersion + ) + const response = yield* test.ping(initialized, { includeProtocolVersion: true }) + assert.strictEqual(response.headers.get("Mcp-Protocol-Version"), protocol.protocolVersion) + })) + } + ) describe("Security", () => { it.effect("MUST validate the Origin header before every MCP route", () => diff --git a/packages/effect/test/unstable/ai/McpProtocol.test.ts b/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts similarity index 67% rename from packages/effect/test/unstable/ai/McpProtocol.test.ts rename to packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts index be950fc52f4..e4452a0752a 100644 --- a/packages/effect/test/unstable/ai/McpProtocol.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpProtocol.test.ts @@ -2,6 +2,8 @@ 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 McpSchema2025_06_18 from "effect/unstable/ai/internal/mcpSchema/v2025_06_18" +import * as McpSchema from "effect/unstable/ai/McpSchema" import * as Rpc from "effect/unstable/rpc/Rpc" import * as RpcGroup from "effect/unstable/rpc/RpcGroup" @@ -27,13 +29,41 @@ const makeTestProtocol = < return McpProtocol.make({ protocolVersion, transport: { - acceptsJsonRpcBatches: true, - requiresVersionHeader: false + acceptsJsonRpcBatches: false, + requiresVersionHeader: true }, clientRpcs: RpcGroup.make(TestRequest), clientNotificationRpcs: RpcGroup.make(), serverRequestRpcs: RpcGroup.make(), - serverNotificationRpcs: RpcGroup.make() + serverNotificationRpcs: RpcGroup.make(), + toReverseClient: () => ({ + listRoots: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "roots/list", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ), + createMessage: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "sampling/createMessage", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ), + elicit: () => + Effect.fail( + new McpSchema.McpReverseOperationUnsupported({ + operation: "elicitation/create", + protocolVersion: "2025-06-18", + reason: "Synthetic test adapter" + }) + ) + }), + projectNotification: () => Effect.succeed(undefined), + normalizeCancellation: () => Effect.succeed({ requestId: "" }) }) } @@ -121,8 +151,8 @@ describe("McpProtocolRegistry", () => { assert.notStrictEqual(selectedRequest.tag, unselectedRequest.tag) assert.notStrictEqual(selectedRequest.tag, "test/shape") - const selectedRpc = registry.clientRpcs.requests.get(selectedRequest.tag) - const unselectedRpc = registry.clientRpcs.requests.get(unselectedRequest.tag) + const selectedRpc = first.clientRpcs.requests.get("test/shape") + const unselectedRpc = second.clientRpcs.requests.get("test/shape") assert.isDefined(selectedRpc) assert.isDefined(unselectedRpc) @@ -138,3 +168,32 @@ describe("McpProtocolRegistry", () => { }) })) }) + +describe("MCP v2025-06-18 schema", () => { + it("accepts resource links in prompt messages", () => { + const message = Schema.decodeUnknownSync(McpSchema2025_06_18.PromptMessage)({ + role: "user", + content: { + type: "resource_link", + uri: "file:///example.txt", + name: "example" + } + }) + + assert.strictEqual(message.content.type, "resource_link") + }) + + it("does not expose future annotations or named extension capabilities", () => { + const annotations = Schema.decodeUnknownSync(McpSchema2025_06_18.Annotations)({ + audience: ["user"], + lastModified: "2026-07-26" + }) + const capabilities = Schema.decodeUnknownSync(McpSchema2025_06_18.ServerCapabilities)({ + completions: {}, + extensions: { "example/extension": { enabled: true } } + }) + + assert.notProperty(annotations, "lastModified") + assert.notProperty(capabilities, "extensions") + }) +}) diff --git a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 7a0ad15a5a7..06e56b6e33b 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -96,6 +96,15 @@ const pingBody = { id: 0 } +const directClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: initializePayload.clientInfo, + initializePayload, + getClient: Effect.die("not used") +}) + const makeTestClientWith = Effect.fnUntraced(function*( serverLayer: Layer.Layer, options?: { @@ -154,6 +163,66 @@ const toolResultText = (result: McpSchema.CallToolResult): string => { } describe("McpServer", () => { + describe("direct service", () => { + it.effect("should return empty contents when a resource URI is unknown", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + + const result = yield* server.findResource("file:///unknown").pipe( + Effect.provideService(McpSchema.McpServerClient, directClient) + ) + + assert.deepStrictEqual(result.contents, []) + })) + + it.effect("should preserve a registered resource handler's typed failure", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + const failure = new McpSchema.InternalError({ message: "resource failed" }) + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: "file:///failure", + name: "failure" + }), + annotations: Context.empty(), + handle: Effect.fail(failure) + }) + + const error = yield* server.findResource("file:///failure").pipe( + Effect.provideService(McpSchema.McpServerClient, directClient), + Effect.flip + ) + + assert.strictEqual(error, failure) + })) + + it.effect("should pass undefined to a low-level tool handler when arguments are omitted", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + let received: unknown = "not called" + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "arguments-omitted", + inputSchema: { + type: "object", + properties: {} + } + }), + annotations: Context.empty(), + handle: (payload) => { + received = payload + return Effect.succeed(new McpSchema.CallToolResult({ content: [] })) + } + }) + + yield* server.callTool({ name: "arguments-omitted" }).pipe( + Effect.provideService(McpSchema.McpServerClient, directClient) + ) + + assert.isUndefined(received) + })) + }) + it.effect("should reject browser Origins by default while accepting Origin-less clients", () => Effect.gen(function*() { const harness = yield* makeHttpHarness(TestServerLayer) @@ -251,7 +320,8 @@ describe("McpServer", () => { }).pipe(Effect.flip) assert.isFalse(handlerInvoked) - assert.instanceOf(error, McpSchema.InvalidParams) + assert.isTrue("code" in error) + if ("code" in error) assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) assert.match(error.message, /Invalid parameters for tool 'OptionalStringTool'/) assert.match(error.message, /Expected string \| undefined/) assert.match(error.message, /at \["signature"\]/) @@ -352,9 +422,11 @@ describe("McpServer", () => { arguments: {} }).pipe(Effect.flip) - assert.instanceOf(error, McpSchema.InvalidParams) - assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) - assert.strictEqual(error.message, "Tool 'UnknownTool' not found") + assert.isTrue("code" in error) + if ("code" in error) { + assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.strictEqual(error.message, "Tool 'UnknownTool' not found") + } })) }) diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts new file mode 100644 index 00000000000..af588d7f5de --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -0,0 +1,1190 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { CurrentLogLevel } from "effect/References" +import * as Schema from "effect/Schema" +import * as McpCore from "effect/unstable/ai/internal/mcpCore" +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 * as Tool from "effect/unstable/ai/Tool" +import * as Toolkit from "effect/unstable/ai/Toolkit" +import * as RpcClient from "effect/unstable/rpc/RpcClient" +import { makeHttpHarness } from "./TestUtils/McpHttpHarness.ts" + +const SharedTool = Tool.make("shared", { + parameters: Tool.EmptyParams, + success: Schema.String +}).annotate(Tool.Title, "Shared tool title") + +const StructuredOnlyTool = Tool.make("structured-only", { + parameters: Tool.EmptyParams, + success: Schema.Struct({ value: Schema.String }) +}).annotate( + McpSchema.EnabledWhen, + (client) => client.protocolVersion === "2025-06-18" +) + +const ValidatedTool = Tool.make("validated", { + parameters: Schema.Struct({ + value: Schema.String + }), + success: Schema.String +}) + +const CapabilityTool = Tool.make("capability", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [McpSchema.McpServerClient] +}) + +const InitializeMetadataTool = Tool.make("initialize-metadata", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [McpSchema.McpServerClient] +}) + +const LogLevelTool = Tool.make("log-level", { + parameters: Tool.EmptyParams, + success: Schema.String, + dependencies: [CurrentLogLevel] +}) + +const CapabilityGatedTool = Tool.make("capability-gated", { + parameters: Tool.EmptyParams, + success: Schema.String +}).annotate( + McpSchema.EnabledWhen, + (client) => + client.clientInfo.name === "allowed-client" && + client.capabilities.roots !== undefined +) + +const TestToolkit = Toolkit.make( + SharedTool, + StructuredOnlyTool, + ValidatedTool, + CapabilityTool, + InitializeMetadataTool, + LogLevelTool, + CapabilityGatedTool +) + +const FamilyResource = McpServer.resource({ + uri: "file:///canonical.txt", + name: "canonical", + description: "Canonical resource", + mimeType: "text/plain", + content: Effect.succeed("canonical resource") +}) + +const FamilyPrompt = McpServer.prompt({ + name: "canonical-prompt", + description: "Canonical prompt", + parameters: { + style: Schema.String + }, + completion: { + style: () => Effect.succeed(["short", "long"]) + }, + content: ({ style }) => Effect.succeed(`Use the ${style} style`) +}) + +const AudioPrompt = McpServer.prompt({ + name: "audio-prompt", + content: () => + Effect.succeed([{ + role: "user", + content: McpSchema.AudioContent.make({ + data: new Uint8Array([1, 2, 3]), + mimeType: "audio/wav" + }) + }]) +}) + +const ResourceLinkPrompt = McpServer.prompt({ + name: "resource-link-prompt", + content: () => + Effect.succeed([{ + role: "user", + content: McpSchema.ResourceLink.make({ + uri: "file:///canonical.txt", + name: "canonical" + }) + }]) +}) + +interface TestState { + sharedInvocations: number + structuredInvocations: number + capabilityInvocations: number +} + +const makeFixture = Effect.fnUntraced(function*() { + const state: TestState = { + sharedInvocations: 0, + structuredInvocations: 0, + capabilityInvocations: 0 + } + const toolkitLayer = McpServer.toolkit(TestToolkit).pipe( + Layer.provideMerge(TestToolkit.toLayer(TestToolkit.of({ + shared: () => + Effect.sync(() => { + state.sharedInvocations++ + return "shared-result" + }), + "structured-only": () => + Effect.sync(() => { + state.structuredInvocations++ + return { value: "structured-result" } + }), + validated: ({ value }) => Effect.succeed(value), + capability: () => + McpServer.clientCapabilities.pipe( + Effect.map((capabilities) => { + state.capabilityInvocations++ + return JSON.stringify(capabilities) + }) + ), + "initialize-metadata": () => + McpSchema.McpServerClient.useSync((client) => JSON.stringify(client.initializePayload._meta)), + "log-level": () => CurrentLogLevel, + "capability-gated": () => Effect.succeed("visible") + }))) + ) + const serverLayer = Layer.mergeAll( + toolkitLayer, + FamilyResource, + FamilyPrompt, + AudioPrompt, + ResourceLinkPrompt + ).pipe( + Layer.provide(McpServer.layerHttp({ + name: "ProtocolAdapterServer", + version: "1.0.0", + path: "/mcp", + protocols: [ + McpProtocol.v2025_06_18, + McpProtocol.v2025_03_26, + McpProtocol.v2024_11_05 + ] + })) + ) + const harness = yield* makeHttpHarness(serverLayer) + return { ...harness, state } +}) + +interface LowLevelTestState { + imageInvocations: number + audioInvocations: number + resourceLinkInvocations: number + projectionMismatchInvocations: number + argumentObservations: Array + duplicateInvocations: Array<"first" | "second"> +} + +const makeLowLevelFixture = Effect.fnUntraced(function*() { + const state: LowLevelTestState = { + imageInvocations: 0, + audioInvocations: 0, + resourceLinkInvocations: 0, + projectionMismatchInvocations: 0, + argumentObservations: [], + duplicateInvocations: [] + } + const registrations = Layer.effectDiscard( + Effect.gen(function*() { + const server = yield* McpServer.McpServer + const makeTool = (name: string, description: string) => + new McpSchema.Tool({ + name, + description, + inputSchema: { + type: "object", + properties: {} + } + }) + + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: "file:///metadata.txt", + name: "metadata-resource", + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + handle: Effect.succeed( + McpSchema.ReadResourceResult.make({ + contents: [{ uri: "file:///metadata.txt", text: "metadata" }], + _meta: { result: "fixture" } + }) + ) + }) + yield* server.addPrompt({ + prompt: new McpSchema.Prompt({ + name: "metadata-prompt", + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + completions: { + value: () => + Effect.succeed( + McpSchema.CompleteResult.make({ + completion: { values: ["metadata"] }, + _meta: { result: "fixture" } + }) + ) + }, + handle: () => + Effect.succeed( + McpSchema.GetPromptResult.make({ + messages: [{ + role: "user", + content: { type: "text", text: "metadata", _meta: { content: "fixture" } } + }], + _meta: { result: "fixture" } + }) + ) + }) + + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "title-precedence", + title: "Canonical title", + inputSchema: { + type: "object", + properties: {} + }, + annotations: { + title: "Legacy annotation title" + } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "title" }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("projection-mismatch", "Claims old support but returns audio"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.projectionMismatchInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "audio", + data: new Uint8Array([1]), + mimeType: "audio/wav" + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("arguments", "Argument normalization probe"), + annotations: Context.empty(), + handle: (payload) => + Effect.sync(() => { + state.argumentObservations.push(payload) + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "arguments" }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("image", "Image for both revisions"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.imageInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "image", + data: new Uint8Array([1, 2, 3]), + mimeType: "image/png" + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("binary-resource", "Binary embedded resource"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ + type: "resource", + resource: { + uri: "file:///binary.dat", + mimeType: "application/octet-stream", + blob: new Uint8Array([7, 8, 9]) + } + }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("annotated-resource", "Annotated embedded resource"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ + type: "resource", + resource: { + uri: "file:///annotated.txt", + mimeType: "text/plain", + text: "annotated", + _meta: { source: "fixture" } + }, + annotations: { + audience: ["assistant"], + priority: 0.75 + }, + _meta: { content: "fixture" } + }], + _meta: { result: "fixture" } + }) + ) + }) + yield* server.addTool({ + tool: makeTool("audio", "Current-revision audio"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.audioInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "audio", + data: new Uint8Array([4, 5, 6]), + mimeType: "audio/wav" + }] + }) + }) + }) + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "schema-output", + description: "Tool with an output schema", + inputSchema: { + type: "object", + properties: {} + }, + outputSchema: { + type: "object", + properties: { + value: { type: "string" } + }, + required: ["value"] + }, + _meta: { descriptor: "fixture" } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "schema" }] + }) + ) + }) + yield* server.addTool({ + tool: makeTool("structured-object", "Object structured content"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "structured" }], + structuredContent: { value: "fixture" } + }) + ) + }) + yield* server.addTool({ + tool: makeTool("structured-scalar", "Scalar structured content"), + annotations: Context.empty(), + handle: () => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: "structured" }], + structuredContent: "fixture" + }) + ) + }) + yield* server.addTool({ + tool: makeTool("resource-link", "Current-revision resource link"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.resourceLinkInvocations++ + return new McpSchema.CallToolResult({ + content: [{ + type: "resource_link", + name: "linked", + title: "Linked resource", + description: "A linked fixture", + uri: "file:///linked.txt", + mimeType: "text/plain", + annotations: { + audience: ["user"], + priority: 0.5 + }, + size: 42, + _meta: { source: "fixture" } + }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("duplicate", "first descriptor"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.duplicateInvocations.push("first") + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "first" }] + }) + }) + }) + yield* server.addTool({ + tool: makeTool("duplicate", "second descriptor"), + annotations: Context.empty(), + handle: () => + Effect.sync(() => { + state.duplicateInvocations.push("second") + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: "second" }] + }) + }) + }) + }) + ) + const serverLayer = registrations.pipe( + Layer.provide(McpServer.layerHttp({ + name: "LowLevelProtocolAdapterServer", + version: "1.0.0", + path: "/mcp", + protocols: [ + McpProtocol.v2025_06_18, + McpProtocol.v2025_03_26, + McpProtocol.v2024_11_05 + ] + })) + ) + const harness = yield* makeHttpHarness(serverLayer) + return { ...harness, state } +}) + +const JsonRpcResponse = Schema.Union([ + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.Number, + result: Schema.Record(Schema.String, Schema.Unknown) + }), + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.NullOr(Schema.Number), + error: Schema.Struct({ + code: Schema.Number, + message: Schema.String + }) + }) +]) +type JsonRpcResponse = typeof JsonRpcResponse.Type + +const decodeJsonRpcResponse = Schema.decodeUnknownEffect(JsonRpcResponse) + +const initialize = Effect.fnUntraced(function*( + post: Effect.Success>["post"], + protocolVersion: "2025-06-18" | "2025-03-26" | "2024-11-05", + options?: { + readonly capabilities?: Record + readonly clientInfo?: { + readonly name: string + readonly version: string + } + } +) { + const response = yield* post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion, + capabilities: options?.capabilities ?? {}, + clientInfo: options?.clientInfo ?? { + name: "ProtocolAdapterClient", + version: "1.0.0" + } + } + }) + const body = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + if (!("result" in body)) { + return yield* Effect.die(`Initialization failed: ${body.error.message}`) + } + const sessionId = response.headers.get("Mcp-Session-Id") + assert.isNotNull(sessionId) + assert.strictEqual(body.result.protocolVersion, protocolVersion) + + let requestId = 1 + const request = Effect.fnUntraced(function*(method: string, params?: unknown) { + const response = yield* post({ + jsonrpc: "2.0", + id: ++requestId, + method, + ...(params === undefined ? {} : { params }) + }, { + "Mcp-Protocol-Version": protocolVersion, + "Mcp-Session-Id": sessionId + }) + return yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + }) + + return { initializeResult: body.result, protocolVersion, request } +}) + +const resultOf = (message: JsonRpcResponse): Record => { + if (!("result" in message)) { + assert.fail(`Expected result, received error ${message.error.code}: ${message.error.message}`) + } + return message.result +} + +const errorOf = (message: JsonRpcResponse): { + readonly code: number + readonly message: string +} => { + if (!("error" in message)) { + assert.fail("Expected error, received result") + } + return message.error +} + +const listedTools = (message: JsonRpcResponse): ReadonlyArray> => { + const tools = resultOf(message).tools + return Schema.decodeUnknownSync(Schema.Array(Schema.Record(Schema.String, Schema.Unknown)))(tools) +} + +const textResult = (message: JsonRpcResponse): string => { + const content = resultOf(message).content + const [first] = Schema.decodeUnknownSync( + Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown)) + )(content) + assert.strictEqual(first.type, "text") + const text = Schema.decodeUnknownSync(Schema.String)(first.text) + return JSON.parse(text) +} + +describe("McpServer protocol adapters", () => { + it.effect("should keep log levels isolated when one session updates its level", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const first = yield* initialize(fixture.post, "2025-06-18") + const second = yield* initialize(fixture.post, "2025-06-18") + + resultOf(yield* first.request("logging/setLevel", { level: "debug" })) + + assert.strictEqual( + textResult(yield* first.request("tools/call", { name: "log-level" })), + "Debug" + ) + assert.strictEqual( + textResult(yield* second.request("tools/call", { name: "log-level" })), + "Info" + ) + })) + + it.effect("should reject prompt content when the negotiated schema cannot represent it", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const marchClient = yield* initialize(fixture.post, "2025-03-26") + const juneClient = yield* initialize(fixture.post, "2025-06-18") + + assert.strictEqual( + errorOf(yield* oldClient.request("prompts/get", { name: "audio-prompt" })).code, + McpSchema.INVALID_PARAMS_ERROR_CODE + ) + assert.property( + resultOf(yield* marchClient.request("prompts/get", { name: "audio-prompt" })), + "messages" + ) + assert.strictEqual( + errorOf(yield* marchClient.request("prompts/get", { name: "resource-link-prompt" })).code, + McpSchema.INVALID_PARAMS_ERROR_CODE + ) + assert.property( + resultOf(yield* juneClient.request("prompts/get", { name: "resource-link-prompt" })), + "messages" + ) + })) + + it.effect("should project canonical notifications when encoding each protocol revision", () => + Effect.gen(function*() { + for ( + const protocol of [ + McpProtocol.v2024_11_05, + McpProtocol.v2025_03_26, + McpProtocol.v2025_06_18 + ] + ) { + const metadata = { source: "test" } + const progress = yield* protocol.projectNotification(McpCore.ServerNotification.Progress({ + progressToken: "task-1", + progress: 1, + total: 2, + message: "half way", + metadata + })) + assert.isDefined(progress) + assert.strictEqual(progress.tag, "notifications/progress") + assert.deepStrictEqual(progress.payload, { + _meta: metadata, + progressToken: "task-1", + progress: 1, + total: 2, + message: protocol.protocolVersion === "2024-11-05" ? undefined : "half way" + }) + + for ( + const [notification, expected] of [ + [ + McpCore.ServerNotification.Cancelled({ requestId: 1, reason: "done", metadata }), + { tag: "notifications/cancelled", payload: { _meta: metadata, requestId: 1, reason: "done" } } + ], + [ + McpCore.ServerNotification.LoggingMessage({ + level: "info", + logger: "test", + data: { ready: true }, + metadata + }), + { + tag: "notifications/message", + payload: { _meta: metadata, level: "info", logger: "test", data: { ready: true } } + } + ], + [ + McpCore.ServerNotification.ResourceUpdated({ uri: "test://resource", metadata }), + { + tag: "notifications/resources/updated", + payload: { _meta: metadata, uri: "test://resource" } + } + ], + [ + McpCore.ServerNotification.ResourcesChanged({ metadata }), + { tag: "notifications/resources/list_changed", payload: { _meta: metadata } } + ], + [ + McpCore.ServerNotification.ToolsChanged({ metadata }), + { tag: "notifications/tools/list_changed", payload: { _meta: metadata } } + ], + [ + McpCore.ServerNotification.PromptsChanged({ metadata }), + { tag: "notifications/prompts/list_changed", payload: { _meta: metadata } } + ] + ] as const + ) { + assert.deepStrictEqual(yield* protocol.projectNotification(notification), expected) + } + } + })) + it.effect("should omit elicitation when the negotiated protocol predates v2025-06-18", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26"] as const) { + const client = yield* initialize(fixture.post, protocolVersion, { + capabilities: { elicitation: {} } + }) + assert.notProperty(client.initializeResult.capabilities, "elicitation") + assert.strictEqual( + textResult(yield* client.request("tools/call", { name: "capability" })), + "{}" + ) + } + })) + + it.effect("should preserve initialize metadata when creating the public request context", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const response = yield* fixture.post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion, + capabilities: {}, + clientInfo: { name: "metadata-client", version: "1.0.0" }, + _meta: { progressToken: protocolVersion } + } + }) + const sessionId = response.headers.get("Mcp-Session-Id") + assert.isNotNull(sessionId) + const toolResponse = yield* fixture.post({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "initialize-metadata" } + }, { + "Mcp-Protocol-Version": protocolVersion, + "Mcp-Session-Id": sessionId + }) + const body = yield* Effect.promise(() => toolResponse.json()).pipe( + Effect.flatMap(decodeJsonRpcResponse) + ) + assert.strictEqual(textResult(body), JSON.stringify({ progressToken: protocolVersion })) + } + })) + + it.effect("should reject elicitation before transport when the protocol predates v2025-06-18", () => + Effect.gen(function*() { + let sends = 0 + const reverseProtocol = yield* RpcClient.Protocol.make(() => + Effect.succeed({ + send: () => + Effect.sync(() => { + sends++ + }), + supportsAck: true, + supportsTransferables: false, + supportsStructuredClone: false + }) + ) + for (const protocol of [McpProtocol.v2024_11_05, McpProtocol.v2025_03_26]) { + const client = yield* protocol.makeReverseClient({ + protocolVersion: protocol.protocolVersion, + clientCapabilities: { elicitation: {} }, + clientInfo: { name: "test", version: "1.0.0" } + }).pipe( + Effect.provideService(RpcClient.Protocol, reverseProtocol) + ) + const error = yield* client.elicit({ + message: "test", + requestedSchema: { + type: "object", + properties: {} + } + }).pipe(Effect.flip) + assert.instanceOf(error, McpSchema.McpReverseOperationUnsupported) + } + 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() + const client = yield* initialize(fixture.post, "2025-03-26") + const shared = listedTools(yield* client.request("tools/list")) + .find((tool) => tool.name === "shared") + + assert.isDefined(shared) + assert.deepStrictEqual(shared.inputSchema, { type: "object" }) + assert.deepStrictEqual(shared.annotations, { + title: "Shared tool title", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + }) + assert.notProperty(shared, "title") + assert.notProperty(shared, "outputSchema") + assert.notProperty(shared, "_meta") + })) + + it.effect("should prefer the canonical title when projecting March annotations", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const marchClient = yield* initialize(fixture.post, "2025-03-26") + const tool = listedTools(yield* marchClient.request("tools/list")) + .find((tool) => tool.name === "title-precedence") + + assert.isDefined(tool) + assert.deepStrictEqual(tool.annotations, { + title: "Canonical title" + }) + })) + + it.effect("should return a typed protocol error when a result cannot be projected", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const client = yield* initialize(fixture.post, "2024-11-05") + const error = errorOf( + yield* client.request("tools/call", { + name: "projection-mismatch" + }) + ) + + assert.strictEqual(error.code, McpSchema.INVALID_PARAMS_ERROR_CODE) + assert.match(error.message, /audio tool content is not supported by MCP 2024-11-05/) + assert.strictEqual(fixture.state.projectionMismatchInvocations, 1) + })) + + it.effect("should route each session through its negotiated RPC group when revisions coexist", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const currentClient = yield* initialize(fixture.post, "2025-06-18") + + const oldShared = listedTools(yield* oldClient.request("tools/list")) + .find((tool) => tool.name === "shared") + const currentShared = listedTools(yield* currentClient.request("tools/list")) + .find((tool) => tool.name === "shared") + + assert.isDefined(oldShared) + assert.isDefined(currentShared) + assert.notProperty(oldShared, "title") + assert.strictEqual(currentShared.title, "Shared tool title") + })) + + for (const protocolVersion of ["2025-06-18", "2024-11-05"] as const) { + it.effect(`should expose the negotiated client profile when serving ${protocolVersion} requests`, () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const advertisedCapabilities = { + roots: { listChanged: true }, + sampling: {} + } + const allowed = yield* initialize(fixture.post, protocolVersion, { + capabilities: advertisedCapabilities, + clientInfo: { name: "allowed-client", version: "2.0.0" } + }) + const denied = yield* initialize(fixture.post, protocolVersion, { + capabilities: {}, + clientInfo: { name: "other-client", version: "1.0.0" } + }) + + const allowedTools = listedTools(yield* allowed.request("tools/list")) + const deniedTools = listedTools(yield* denied.request("tools/list")) + assert.isTrue(allowedTools.some((tool) => tool.name === "capability-gated")) + assert.isFalse(deniedTools.some((tool) => tool.name === "capability-gated")) + + const observed = JSON.parse(textResult( + yield* allowed.request("tools/call", { name: "capability" }) + )) + assert.deepStrictEqual(observed, advertisedCapabilities) + assert.strictEqual(fixture.state.capabilityInvocations, 1) + })) + } + + it.effect("should project the exact descriptor shape when listing tools for each revision", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + const currentShared = currentTools.find((tool) => tool.name === "shared") + const oldShared = oldTools.find((tool) => tool.name === "shared") + + assert.isDefined(currentShared) + assert.isDefined(oldShared) + assert.strictEqual(currentShared.title, "Shared tool title") + assert.deepStrictEqual(currentShared.annotations, { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + }) + assert.notProperty(oldShared, "title") + assert.notProperty(oldShared, "annotations") + assert.notProperty(oldShared, "_meta") + assert.notProperty(oldShared, "outputSchema") + })) + + it.effect("should project outputSchema only when the protocol revision supports it", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + const currentSchemaOutput = currentTools.find((tool) => tool.name === "schema-output") + const oldSchemaOutput = oldTools.find((tool) => tool.name === "schema-output") + assert.isDefined(currentSchemaOutput) + assert.isDefined(oldSchemaOutput) + assert.deepStrictEqual(currentSchemaOutput.outputSchema, { + type: "object", + properties: { + value: { type: "string" } + }, + required: ["value"] + }) + assert.deepStrictEqual(currentSchemaOutput._meta, { descriptor: "fixture" }) + assert.notProperty(oldSchemaOutput, "outputSchema") + assert.notProperty(oldSchemaOutput, "_meta") + })) + + it.effect("should encode binary content for every revision that can represent it", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const image = resultOf(yield* client.request("tools/call", { name: "image" })) + assert.deepStrictEqual(image.content, [{ + type: "image", + data: "AQID", + mimeType: "image/png" + }]) + + const embedded = resultOf( + yield* client.request("tools/call", { name: "binary-resource" }) + ) + assert.deepStrictEqual(embedded.content, [{ + type: "resource", + resource: { + uri: "file:///binary.dat", + mimeType: "application/octet-stream", + blob: "BwgJ" + } + }]) + } + + for (const protocolVersion of ["2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const audio = resultOf(yield* client.request("tools/call", { name: "audio" })) + assert.deepStrictEqual(audio.content, [{ + type: "audio", + data: "BAUG", + mimeType: "audio/wav" + }]) + } + })) + + it.effect("should project structured content according to the negotiated revision", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26"] 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.notProperty(objectResult, "structuredContent") + 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" }) + + const scalarError = errorOf( + yield* currentClient.request("tools/call", { name: "structured-scalar" }) + ) + 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/) + })) + + it.effect("should preserve only supported metadata when projecting embedded resource content", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + + for (const protocolVersion of ["2025-06-18", "2025-03-26", "2024-11-05"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const result = resultOf( + yield* client.request("tools/call", { name: "annotated-resource" }) + ) + const [content] = Schema.decodeUnknownSync( + Schema.NonEmptyArray(Schema.Record(Schema.String, Schema.Unknown)) + )(result.content) + assert.deepStrictEqual(result._meta, { result: "fixture" }) + const resource = Schema.decodeUnknownSync( + Schema.Record(Schema.String, Schema.Unknown) + )(content.resource) + assert.deepStrictEqual(content.annotations, { + audience: ["assistant"], + priority: 0.75 + }) + assert.strictEqual(resource.uri, "file:///annotated.txt") + assert.strictEqual(resource.mimeType, "text/plain") + assert.strictEqual(resource.text, "annotated") + if (protocolVersion === "2025-06-18") { + assert.deepStrictEqual(resource._meta, { source: "fixture" }) + assert.deepStrictEqual(content._meta, { content: "fixture" }) + } else { + assert.notProperty(resource, "_meta") + assert.notProperty(content, "_meta") + } + } + })) + + it.effect("should preserve result metadata while omitting June-only metadata for older revisions", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + for (const protocolVersion of ["2024-11-05", "2025-03-26", "2025-06-18"] as const) { + const client = yield* initialize(fixture.post, protocolVersion) + const resources = resultOf(yield* client.request("resources/list")) + const resource = (resources.resources as Array>).find( + (_) => _.uri === "file:///metadata.txt" + )! + const read = resultOf( + yield* client.request("resources/read", { uri: "file:///metadata.txt" }) + ) + const prompts = resultOf(yield* client.request("prompts/list")) + const promptDescriptor = (prompts.prompts as Array>).find( + (_) => _.name === "metadata-prompt" + )! + const prompt = resultOf( + yield* client.request("prompts/get", { name: "metadata-prompt" }) + ) + const completion = resultOf( + yield* client.request("completion/complete", { + ref: { type: "ref/prompt", name: "metadata-prompt" }, + argument: { name: "value", value: "m" } + }) + ) + + assert.deepStrictEqual(read._meta, { result: "fixture" }) + assert.deepStrictEqual(prompt._meta, { result: "fixture" }) + assert.deepStrictEqual(completion._meta, { result: "fixture" }) + const content = (prompt.messages as Array>)[0].content + if (protocolVersion === "2025-06-18") { + assert.deepStrictEqual(resource._meta, { descriptor: "fixture" }) + assert.deepStrictEqual(promptDescriptor._meta, { descriptor: "fixture" }) + assert.deepStrictEqual(content._meta, { content: "fixture" }) + } else { + assert.notProperty(resource, "_meta") + assert.notProperty(promptDescriptor, "_meta") + assert.notProperty(content, "_meta") + } + } + })) + + it.effect("should hide and reject a tool when its declaration is incompatible with the revision", () => + Effect.gen(function*() { + const fixture = yield* makeFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + + assert.isTrue(currentTools.some((tool) => tool.name === "structured-only")) + assert.isFalse(oldTools.some((tool) => tool.name === "structured-only")) + + const oldCall = yield* oldClient.request("tools/call", { + name: "structured-only", + arguments: {} + }) + assert.property(oldCall, "error") + assert.strictEqual(fixture.state.structuredInvocations, 0) + })) + + it.effect("should reject after invocation when a tool result cannot be projected", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + const currentTools = listedTools(yield* currentClient.request("tools/list")) + const oldTools = listedTools(yield* oldClient.request("tools/list")) + assert.isTrue(currentTools.some((tool) => tool.name === "audio")) + assert.isTrue(currentTools.some((tool) => tool.name === "resource-link")) + assert.isTrue(oldTools.some((tool) => tool.name === "audio")) + assert.isTrue(oldTools.some((tool) => tool.name === "resource-link")) + + assert.property( + yield* oldClient.request("tools/call", { name: "audio" }), + "error" + ) + assert.property( + yield* oldClient.request("tools/call", { name: "resource-link" }), + "error" + ) + assert.strictEqual(fixture.state.audioInvocations, 1) + assert.strictEqual(fixture.state.resourceLinkInvocations, 1) + })) + + it.effect("should return a typed failure when structured tool content is not valid JSON", () => + Effect.gen(function*() { + const server = yield* McpServer.McpServer.make + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "invalid-structured-content", + inputSchema: { + type: "object", + properties: {} + } + }), + annotations: Context.empty(), + handle: () => + Effect.succeed({ + content: [{ type: "text", text: "invalid" }], + structuredContent: Symbol("not-json") + } as unknown as McpSchema.CallToolResult) + }) + + const error = yield* server.callTool({ + name: "invalid-structured-content", + arguments: {} + }).pipe( + Effect.provideService( + McpSchema.McpServerClient, + McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { + name: "direct-client", + version: "1.0.0" + }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { + name: "direct-client", + version: "1.0.0" + } + }, + getClient: Effect.die("not used") + }) + ), + Effect.flip + ) + + assert.instanceOf(error, McpSchema.InvalidParams) + assert.strictEqual( + error.message, + "Tool 'invalid-structured-content' returned structured content that is not valid JSON" + ) + })) + + it("should reject a tool schema when inputSchema is not an object", () => { + assert.throws(() => + Schema.decodeUnknownSync(McpSchema.Tool)({ + name: "invalid", + inputSchema: "anything" + }) + ) + }) + + it.effect("should list and invoke the latest tool when a name is registered twice", () => + Effect.gen(function*() { + const fixture = yield* makeLowLevelFixture() + const currentClient = yield* initialize(fixture.post, "2025-06-18") + const oldClient = yield* initialize(fixture.post, "2024-11-05") + + for (const client of [currentClient, oldClient]) { + const duplicates = listedTools( + yield* client.request("tools/list") + ).filter((tool) => tool.name === "duplicate") + assert.lengthOf(duplicates, 1) + assert.strictEqual(duplicates[0]?.description, "second descriptor") + + const result = resultOf( + yield* client.request("tools/call", { name: "duplicate" }) + ) + assert.deepStrictEqual(result.content, [{ type: "text", text: "second" }]) + } + assert.deepStrictEqual(fixture.state.duplicateInvocations, ["second", "second"]) + })) +}) diff --git a/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts b/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts index 6d6e49f40f9..78f29cca6dc 100644 --- a/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts +++ b/packages/effect/test/unstable/ai/McpServer/TestUtils/McpServerLayer.ts @@ -2,6 +2,7 @@ import { constVoid } from "effect/Function" import * as Layer from "effect/Layer" import * as Logger from "effect/Logger" import * as References from "effect/References" +import type * as Schema from "effect/Schema" import * as McpProtocol from "effect/unstable/ai/McpProtocol" import * as McpServer from "effect/unstable/ai/McpServer" @@ -16,7 +17,8 @@ export const makeServerLayer = (options: { ...Array ] | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined + readonly extensions?: Readonly> | undefined + readonly allowedOrigins?: ReadonlyArray | undefined }) => McpServer.layerHttp({ name: options.name, diff --git a/packages/effect/test/unstable/ai/McpServer/utils.ts b/packages/effect/test/unstable/ai/McpServer/utils.ts deleted file mode 100644 index fc1aea5b468..00000000000 --- a/packages/effect/test/unstable/ai/McpServer/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as Effect from "effect/Effect" -import { constVoid } from "effect/Function" -import * as Layer from "effect/Layer" -import * as Logger from "effect/Logger" -import * as References from "effect/References" -import * as McpProtocol from "effect/unstable/ai/McpProtocol" -import * as McpServer from "effect/unstable/ai/McpServer" -import * as HttpRouter from "effect/unstable/http/HttpRouter" - -export const MCP_ENDPOINT = "http://localhost/mcp" - -const noopLogger = Logger.make(constVoid) - -export const makeServerLayer = (options: { - readonly name: string - readonly version?: string | undefined - readonly extensions?: Record<`${string}/${string}`, unknown> | undefined -}) => - McpServer.layerHttp({ - name: options.name, - version: options.version ?? "1.0.0", - path: "/mcp", - protocols: [McpProtocol.v2025_06_18], - extensions: options.extensions - }).pipe( - Layer.provideMerge(Layer.succeed( - References.CurrentLoggers, - new Set([noopLogger]) - )), - Layer.orDie - ) - -export const makeWebHandler = Effect.fnUntraced(function*( - serverLayer: Layer.Layer, - options?: { - readonly routerLayer?: Layer.Layer | undefined - } -) { - const appLayer = options?.routerLayer ? Layer.merge(serverLayer, options.routerLayer) : serverLayer - const { dispose, handler } = HttpRouter.toWebHandler(appLayer, { disableLogger: true }) - yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) - return handler -}) - -export const makeRawHttpHarness = Effect.fnUntraced(function*( - serverLayer: Layer.Layer -) { - const handler = yield* makeWebHandler(serverLayer) - const post = (body: unknown, headers?: HeadersInit) => - Effect.promise(() => - handler( - new Request(MCP_ENDPOINT, { - method: "POST", - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - ...headers - }, - body: JSON.stringify(body) - }) - ) - ) - return { post } as const -}) 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 new file mode 100644 index 00000000000..288e61c5176 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/v2024_11_05.test.ts @@ -0,0 +1,44 @@ +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" +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" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" + +const protocol = McpProtocol.v2024_11_05 +const testLayer = McpConformance.layer(protocol) + +LifecycleTest.suite(protocol, testLayer) +BaseProtocolTest.suite(protocol, testLayer) +TransportsTest.suite(protocol, testLayer) +UtilitiesTest.suite(protocol, testLayer) +LoggingTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +ToolsTest.suite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +RootsTest.suite(protocol, testLayer) +SamplingTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Completion", () => { + describe("Capabilities", () => { + it.effect("MUST NOT advertise completions", () => + Effect.gen(function*() { + const test = yield* McpConformance.McpConformance + const initialized = yield* test.initialize({ server: "features" }) + + assert.notProperty(initialized.message.result.capabilities, "completions") + })) + }) + }) +}) 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 new file mode 100644 index 00000000000..d30326ff7f9 --- /dev/null +++ b/packages/effect/test/unstable/ai/McpServer/v2025_03_26.test.ts @@ -0,0 +1,134 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +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 LifecycleTest from "./McpConformance/LifecycleTest.ts" +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" +import * as UtilitiesTest from "./McpConformance/UtilitiesTest.ts" +import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts" + +const protocol = McpProtocol.v2025_03_26 +const testLayer = makeMcpConformanceLayer(protocol) + +LifecycleTest.suite(protocol, testLayer) +BaseProtocolTest.suite(protocol, testLayer) +TransportsTest.suite(protocol, testLayer) +UtilitiesTest.suite(protocol, testLayer) +LoggingTest.suite(protocol, testLayer) +CompletionTest.suite(protocol, testLayer) +ToolsTest.suite(protocol, testLayer) +ResourcesTest.suite(protocol, testLayer) +PromptsTest.suite(protocol, testLayer) +RootsTest.suite(protocol, testLayer) +SamplingTest.suite(protocol, testLayer) + +it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { + describe("Transport-specific behavior", () => { + it.effect("MUST accept operational JSON-RPC batches over stdio", () => + Effect.gen(function*() { + const fixture = yield* makeMcpStdioHarness(protocol) + yield* fixture.sendRaw({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: protocol.protocolVersion, + capabilities: {}, + clientInfo: { name: "stdio-client", version: "1.0.0" } + } + }) + yield* fixture.takeFrame + yield* fixture.sendRaw([ + { jsonrpc: "2.0", id: 2, method: "ping", params: {} }, + { jsonrpc: "2.0", id: 3, method: "ping", params: {} } + ]) + + assert.deepStrictEqual(yield* fixture.takeFrame, [ + { jsonrpc: "2.0", id: 2, result: {} }, + { jsonrpc: "2.0", id: 3, result: {} } + ]) + })) + + it.effect("SERVER rejects initialize as part of a JSON-RPC batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post([test.initializeRequest()]) + + assert.isAtLeast(response.status, 400) + assert.isNull(response.headers.get("Mcp-Session-Id")) + })) + + it.effect("MUST reject an empty JSON-RPC batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const response = yield* test.post([]) + const error = yield* test.decodeError(response) + + assert.strictEqual(error.id, null) + assert.strictEqual(error.error.code, -32600) + })) + + it.effect("MUST accept an operational mixed batch and correlate responses by id", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.send(initialized, [ + test.pingRequest(41), + test.initializedNotification, + { jsonrpc: "2.0", id: 42, method: "unknown/method" } + ], { includeProtocolVersion: false }) + const messages = yield* Effect.promise< + ReadonlyArray<{ + readonly jsonrpc?: unknown + readonly id?: unknown + readonly result?: unknown + readonly error?: { readonly code?: unknown } + }> + >(() => response.json()) + const success = messages.find((message) => message.id === 41) + const failure = messages.find((message) => message.id === 42) + + assert.deepStrictEqual(success, { jsonrpc: "2.0", id: 41, result: {} }) + assert.strictEqual(failure?.error?.code, McpSchema.METHOD_NOT_FOUND_ERROR_CODE) + })) + + it.effect("MUST return 202 with no body for an accepted notification-only batch", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.send(initialized, [ + test.initializedNotification, + { jsonrpc: "2.0", method: "notifications/roots/list_changed" } + ], { includeProtocolVersion: false }) + + assert.strictEqual(response.status, 202) + assert.strictEqual(yield* Effect.promise(() => response.text()), "") + })) + + it.effect("MUST operate without the later protocol-version header", () => + Effect.gen(function*() { + const test = yield* McpConformance + const initialized = yield* test.initialize() + assert.isNotNull(initialized.sessionId) + + const response = yield* test.ping(initialized, { + includeProtocolVersion: false + }) + + assert.strictEqual(response.status, 200) + })) + }) +}) 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 4f3bbf5071f..f20662084e2 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 @@ -42,32 +42,6 @@ SamplingTest.suite(protocol, testLayer) ElicitationTest.suite(protocol, testLayer) it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { - describe("Utilities", () => { - describe("Progress", () => { - // NOTE: Smoke test only. The client capability accepts this one-way notification, - // but McpServer does not expose an observer for its decoded payload. - it.effect("SCHEMA accepts the optional progress message", () => - Effect.gen(function*() { - const test = yield* McpConformance - const initialized = yield* test.initialize() - yield* test.notifyInitialized(initialized) - - const response = yield* test.send(initialized, { - jsonrpc: "2.0", - method: "notifications/progress", - params: { - progressToken: "task-with-message", - progress: 1, - message: "Working" - } - }) - - assert.strictEqual(response.status, 202) - assert.strictEqual(yield* Effect.promise(() => response.text()), "") - })) - }) - }) - describe("Transport-specific behavior", () => { it.effect("MUST reject JSON-RPC batches", () => Effect.gen(function*() { diff --git a/packages/effect/typetest/unstable/ai/McpServer.tst.ts b/packages/effect/typetest/unstable/ai/McpServer.tst.ts index 14a56309476..83d74cac495 100644 --- a/packages/effect/typetest/unstable/ai/McpServer.tst.ts +++ b/packages/effect/typetest/unstable/ai/McpServer.tst.ts @@ -70,9 +70,11 @@ describe("McpServer", () => { }) }) - it("should expose the supported protocol adapter", () => { - expect<"v2025_06_18">().type.toBeAssignableTo() - expect().type.toBe<"2025-06-18">() + it("should expose every historical protocol adapter", () => { + expect().type.toBe< + "v2024_11_05" | "v2025_03_26" | "v2025_06_18" + >() + expect().type.toBe<"2024-11-05" | "2025-03-26" | "2025-06-18">() }) it("should expose invalid protocol declarations as typed constructor failures", () => { From aa171b89137c09b8adf5ed468ef4578bd5b53048 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sat, 1 Aug 2026 09:54:26 +0200 Subject: [PATCH 5/5] fix: feedback and refinements --- packages/effect/src/unstable/ai/McpServer.ts | 29 +++++++++++++++---- .../unstable/ai/McpServer/McpServer.test.ts | 10 ++++--- .../ai/McpServer/ProtocolAdapters.test.ts | 6 +++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index b12b4ad9375..12476dbc042 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -401,7 +401,12 @@ export class McpServer extends Context.Service Effect.gen(function*() { - resources.push(options) + const existingIndex = resources.findIndex(({ resource }) => resource.uri === options.resource.uri) + if (existingIndex === -1) { + resources.push(options) + } else { + resources[existingIndex] = options + } yield* internalCore.resources.register({ descriptor: options.resource, isVisible: (profile) => { @@ -421,7 +426,14 @@ export class McpServer extends Context.Service Effect.gen(function*() { - resourceTemplates.push({ template, annotations }) + const existingIndex = resourceTemplates.findIndex(({ template: current }) => + current.uriTemplate === template.uriTemplate + ) + if (existingIndex === -1) { + resourceTemplates.push({ template, annotations }) + } else { + resourceTemplates[existingIndex] = { template, annotations } + } const templateMatcher = makeUriMatcher() templateMatcher.add(routerPath, true) yield* internalCore.resources.registerTemplate({ @@ -469,7 +481,7 @@ export class McpServer extends Context.Service Effect.gen(function*() { const client = yield* McpServerClient - const result = yield* internalCore.resources.read(uri, { + return yield* internalCore.resources.read(uri, { clientId: client.clientId, protocol: { protocolVersion: client.protocolVersion, @@ -479,16 +491,21 @@ export class McpServer extends Context.Service Effect.succeed({ contents: [] })) + Effect.catchTag("ResourceNotFound", (error) => + Effect.fail(new InvalidParams({ message: `Resource '${error.uri}' not found` }))) ) - return result }), get prompts() { return prompts }, addPrompt: (options) => Effect.gen(function*() { - prompts.push(options) + const existingIndex = prompts.findIndex(({ prompt }) => prompt.name === options.prompt.name) + if (existingIndex === -1) { + prompts.push(options) + } else { + prompts[existingIndex] = options + } yield* internalCore.prompts.register({ descriptor: options.prompt, isVisible: (profile) => { diff --git a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 06e56b6e33b..635f1da7772 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -164,15 +164,17 @@ const toolResultText = (result: McpSchema.CallToolResult): string => { describe("McpServer", () => { describe("direct service", () => { - it.effect("should return empty contents when a resource URI is unknown", () => + it.effect("should fail when a resource URI is unknown", () => Effect.gen(function*() { const server = yield* McpServer.McpServer.make - const result = yield* server.findResource("file:///unknown").pipe( - Effect.provideService(McpSchema.McpServerClient, directClient) + const error = yield* server.findResource("file:///unknown").pipe( + Effect.provideService(McpSchema.McpServerClient, directClient), + Effect.flip ) - assert.deepStrictEqual(result.contents, []) + assertTrue(error instanceof McpSchema.InvalidParams) + assert.strictEqual(error.message, "Resource 'file:///unknown' not found") })) it.effect("should preserve a registered resource handler's typed failure", () => diff --git a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts index af588d7f5de..dfe1ea88cd7 100644 --- a/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/ProtocolAdapters.test.ts @@ -805,7 +805,11 @@ describe("McpServer protocol adapters", () => { assert.isDefined(tool) assert.deepStrictEqual(tool.annotations, { - title: "Canonical title" + title: "Canonical title", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true }) }))