diff --git a/.changeset/mcp-v2026-plain-descriptors.md b/.changeset/mcp-v2026-plain-descriptors.md new file mode 100644 index 00000000000..eae92351f18 --- /dev/null +++ b/.changeset/mcp-v2026-plain-descriptors.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix 2026-07-28 MCP listings failing for resources, resource templates and prompts registered as plain objects. diff --git a/packages/effect/src/ai/internal/mcpProtocol/v2026_07_28.ts b/packages/effect/src/ai/internal/mcpProtocol/v2026_07_28.ts index 7bfd4e779c9..f51c72476e0 100644 --- a/packages/effect/src/ai/internal/mcpProtocol/v2026_07_28.ts +++ b/packages/effect/src/ai/internal/mcpProtocol/v2026_07_28.ts @@ -203,13 +203,12 @@ const projectContent = Match.type().pipe( Match.exhaustive ) -const projectResource = (resource: PublicMcpSchema.Resource) => - McpProtocol.transcode(PublicMcpSchema.Resource, McpSchema.Resource, resource) +// Registrations may be plain objects, so encode with the wire struct. +const projectResource = (resource: PublicMcpSchema.Resource) => Schema.encodeEffect(McpSchema.Resource)(resource) const projectResourceTemplate = (resourceTemplate: PublicMcpSchema.ResourceTemplate) => - McpProtocol.transcode(PublicMcpSchema.ResourceTemplate, McpSchema.ResourceTemplate, resourceTemplate) + Schema.encodeEffect(McpSchema.ResourceTemplate)(resourceTemplate) -const projectPrompt = (prompt: PublicMcpSchema.Prompt) => - McpProtocol.transcode(PublicMcpSchema.Prompt, McpSchema.Prompt, prompt) +const projectPrompt = (prompt: PublicMcpSchema.Prompt) => Schema.encodeEffect(McpSchema.Prompt)(prompt) const projectTool = (tool: PublicMcpSchema.Tool) => McpProtocol.transcode(PublicMcpSchema.Tool, McpSchema.Tool, tool) diff --git a/packages/effect/test/ai/McpServer/v2026_07_28.test.ts b/packages/effect/test/ai/McpServer/v2026_07_28.test.ts index aae9dc34c32..6d101144032 100644 --- a/packages/effect/test/ai/McpServer/v2026_07_28.test.ts +++ b/packages/effect/test/ai/McpServer/v2026_07_28.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import * as McpProtocol from "effect/ai/McpProtocol" import * as McpSchema from "effect/ai/McpSchema" import * as McpServer from "effect/ai/McpServer" +import * as Context from "effect/Context" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Base64 from "effect/encoding/Base64" @@ -544,6 +545,53 @@ it.layer(testLayer)(`Mcp Conformance (${protocol.protocolVersion})`, (it) => { ]) })) + it.effect("should list resources, templates and prompts registered as plain objects", () => + Effect.gen(function*() { + const icon = { src: "https://example.com/icon.png" } + const registrations = Layer.effectDiscard(Effect.gen(function*() { + const server = yield* McpServer.McpServer + yield* server.addResource({ + resource: { uri: "file:///plain", name: "Plain", icons: [icon] }, + annotations: Context.empty(), + handle: Effect.succeed({ contents: [] }) + }) + yield* server.addResourceTemplate({ + template: { uriTemplate: "file:///plain/{id}", name: "PlainTemplate", icons: [icon] }, + annotations: Context.empty(), + routerPath: "file:///plain/:id", + completions: {}, + handle: () => Effect.succeed({ contents: [] }) + }) + yield* server.addPrompt({ + prompt: { name: "PlainPrompt", icons: [icon] }, + annotations: Context.empty(), + completions: {}, + handle: () => Effect.succeed({ messages: [] }) + }) + })) + const harness = yield* makeHttpHarness(registrations.pipe(Layer.provideMerge(makeServerLayer({ + name: "PlainDescriptorsServer", + protocols: [protocol] + })))) + const list = Effect.fnUntraced(function*(method: string) { + const response = yield* harness.post(request(method, method), headers(method)) + return Schema.decodeUnknownSync(Schema.Struct({ result: Schema.JsonObject }))( + yield* readMcpHttpResponse(response) + ).result + }) + + const resources = yield* list("resources/list") + assert.deepStrictEqual(resources.resources, [{ uri: "file:///plain", name: "Plain", icons: [icon] }]) + const templates = yield* list("resources/templates/list") + assert.deepStrictEqual(templates.resourceTemplates, [{ + uriTemplate: "file:///plain/{id}", + name: "PlainTemplate", + icons: [icon] + }]) + const prompts = yield* list("prompts/list") + assert.deepStrictEqual(prompts.prompts, [{ name: "PlainPrompt", icons: [icon] }]) + })) + // SEP-2549 cache hints are server-advertised freshness; omitted families and resource reads keep the stale default. it.effect("should advertise configured cache hints and keep defaults for other results", () => Effect.gen(function*() {