From df65630cbf179f0766c16d4f995769145b183558 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 15:59:45 +1200 Subject: [PATCH 1/3] Emit MCP tool output schemas --- .changeset/mcp-tool-output-schema.md | 5 +++++ packages/effect/src/unstable/ai/McpSchema.ts | 4 ++++ packages/effect/src/unstable/ai/McpServer.ts | 3 +++ .../effect/test/unstable/ai/McpServer.test.ts | 20 +++++++++++++++++-- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-tool-output-schema.md diff --git a/.changeset/mcp-tool-output-schema.md b/.changeset/mcp-tool-output-schema.md new file mode 100644 index 00000000000..3c0399361ac --- /dev/null +++ b/.changeset/mcp-tool-output-schema.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Include typed tool output schemas in MCP `tools/list` responses. diff --git a/packages/effect/src/unstable/ai/McpSchema.ts b/packages/effect/src/unstable/ai/McpSchema.ts index 98636c002d1..7da9c965dd2 100644 --- a/packages/effect/src/unstable/ai/McpSchema.ts +++ b/packages/effect/src/unstable/ai/McpSchema.ts @@ -1471,6 +1471,10 @@ export class Tool extends Schema.Class( * A JSON Schema object defining the expected parameters for the tool. */ inputSchema: Schema.Any, + /** + * An optional JSON Schema object defining the expected output of the tool. + */ + outputSchema: optional(Schema.Any), /** * Optional additional tool information. */ diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 6efbf172dba..1c566de3c7c 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -888,6 +888,9 @@ export const registerToolkit: >( name: tool.name, description: Tool.getDescription(tool), inputSchema: Tool.getJsonSchema(tool), + ...(tool.successSchema === Schema.Void || tool.successSchema === Schema.Unknown ? {} : { + outputSchema: Tool.getJsonSchemaFromSchema(tool.successSchema) + }), annotations: { ...(Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), diff --git a/packages/effect/test/unstable/ai/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer.test.ts index df4c376e326..a92cc6e5f5a 100644 --- a/packages/effect/test/unstable/ai/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer.test.ts @@ -42,14 +42,17 @@ const DefectTool = Tool.make("DefectTool", { success: Schema.String }) -const TestToolkit = Toolkit.make(OptionalStringTool, PublicFailureTool, InternalAiErrorTool, DefectTool) +const UntypedTool = Tool.make("UntypedTool") + +const TestToolkit = Toolkit.make(OptionalStringTool, PublicFailureTool, InternalAiErrorTool, DefectTool, UntypedTool) type TestToolkitHandlers = Toolkit.HandlersFrom> const testToolkitHandlers = TestToolkit.of({ OptionalStringTool: ({ signature }) => Effect.succeed(signature ?? "omitted"), PublicFailureTool: () => Effect.fail(new Error("Public failure")), InternalAiErrorTool: () => Effect.fail(new AiError.RateLimitError({})), - DefectTool: () => Effect.die("private defect details") + DefectTool: () => Effect.die("private defect details"), + UntypedTool: () => Effect.void }) const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." @@ -243,6 +246,19 @@ describe("McpServer", () => { strictEqual(response.status, 404) })) describe("registerToolkit", () => { + it.effect("lists output schemas only for tools with typed success schemas", () => + Effect.gen(function*() { + const client = yield* makeToolkitTestClient() + + const result = yield* client["tools/list"]({}) + const typedTool = result.tools.find((tool) => tool.name === "OptionalStringTool") + const untypedTool = result.tools.find((tool) => tool.name === "UntypedTool") + + assert.deepStrictEqual(typedTool?.outputSchema, { type: "string" }) + assertTrue(untypedTool !== undefined) + assert.isFalse("outputSchema" in untypedTool) + })) + it.effect("returns concise parameter-validation errors without invoking the handler", () => Effect.gen(function*() { let handlerInvoked = false From dda0fb8d97bef01bbb018c02ae15e007d4b982f0 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 16:06:43 +1200 Subject: [PATCH 2/3] Check MCP success schema AST --- packages/effect/src/unstable/ai/McpServer.ts | 2 +- .../effect/test/unstable/ai/McpServer.test.ts | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index 1c566de3c7c..ab6039f449e 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -888,7 +888,7 @@ export const registerToolkit: >( name: tool.name, description: Tool.getDescription(tool), inputSchema: Tool.getJsonSchema(tool), - ...(tool.successSchema === Schema.Void || tool.successSchema === Schema.Unknown ? {} : { + ...(SchemaAST.isVoid(tool.successSchema.ast) || SchemaAST.isUnknown(tool.successSchema.ast) ? {} : { outputSchema: Tool.getJsonSchemaFromSchema(tool.successSchema) }), annotations: { diff --git a/packages/effect/test/unstable/ai/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer.test.ts index a92cc6e5f5a..9b9240b4a4a 100644 --- a/packages/effect/test/unstable/ai/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer.test.ts @@ -44,7 +44,18 @@ const DefectTool = Tool.make("DefectTool", { const UntypedTool = Tool.make("UntypedTool") -const TestToolkit = Toolkit.make(OptionalStringTool, PublicFailureTool, InternalAiErrorTool, DefectTool, UntypedTool) +const AnnotatedVoidTool = Tool.make("AnnotatedVoidTool", { + success: Schema.Void.annotate({ description: "No output" }) +}) + +const TestToolkit = Toolkit.make( + OptionalStringTool, + PublicFailureTool, + InternalAiErrorTool, + DefectTool, + UntypedTool, + AnnotatedVoidTool +) type TestToolkitHandlers = Toolkit.HandlersFrom> const testToolkitHandlers = TestToolkit.of({ @@ -52,7 +63,8 @@ const testToolkitHandlers = TestToolkit.of({ PublicFailureTool: () => Effect.fail(new Error("Public failure")), InternalAiErrorTool: () => Effect.fail(new AiError.RateLimitError({})), DefectTool: () => Effect.die("private defect details"), - UntypedTool: () => Effect.void + UntypedTool: () => Effect.void, + AnnotatedVoidTool: () => Effect.void }) const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." @@ -253,10 +265,13 @@ describe("McpServer", () => { const result = yield* client["tools/list"]({}) const typedTool = result.tools.find((tool) => tool.name === "OptionalStringTool") const untypedTool = result.tools.find((tool) => tool.name === "UntypedTool") + const annotatedVoidTool = result.tools.find((tool) => tool.name === "AnnotatedVoidTool") assert.deepStrictEqual(typedTool?.outputSchema, { type: "string" }) assertTrue(untypedTool !== undefined) assert.isFalse("outputSchema" in untypedTool) + assertTrue(annotatedVoidTool !== undefined) + assert.isFalse("outputSchema" in annotatedVoidTool) })) it.effect("returns concise parameter-validation errors without invoking the handler", () => From 63cd95a12864ff3d6a2f8696ac6081bf8a10056e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Thu, 30 Jul 2026 16:11:52 +1200 Subject: [PATCH 3/3] Emit schemas for structured MCP results --- packages/effect/src/unstable/ai/McpServer.ts | 5 ++--- .../effect/test/unstable/ai/McpServer.test.ts | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index ab6039f449e..eb2be12dc1b 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -884,13 +884,12 @@ 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 mcpTool = new McpTool({ name: tool.name, description: Tool.getDescription(tool), inputSchema: Tool.getJsonSchema(tool), - ...(SchemaAST.isVoid(tool.successSchema.ast) || SchemaAST.isUnknown(tool.successSchema.ast) ? {} : { - outputSchema: Tool.getJsonSchemaFromSchema(tool.successSchema) - }), + ...(outputSchema.type === "object" ? { outputSchema } : {}), annotations: { ...(Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), diff --git a/packages/effect/test/unstable/ai/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer.test.ts index 9b9240b4a4a..f81383d91f7 100644 --- a/packages/effect/test/unstable/ai/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer.test.ts @@ -44,6 +44,10 @@ const DefectTool = Tool.make("DefectTool", { const UntypedTool = Tool.make("UntypedTool") +const StructuredResultTool = Tool.make("StructuredResultTool", { + success: Schema.Struct({ answer: Schema.String }) +}) + const AnnotatedVoidTool = Tool.make("AnnotatedVoidTool", { success: Schema.Void.annotate({ description: "No output" }) }) @@ -54,6 +58,7 @@ const TestToolkit = Toolkit.make( InternalAiErrorTool, DefectTool, UntypedTool, + StructuredResultTool, AnnotatedVoidTool ) type TestToolkitHandlers = Toolkit.HandlersFrom> @@ -64,6 +69,7 @@ const testToolkitHandlers = TestToolkit.of({ InternalAiErrorTool: () => Effect.fail(new AiError.RateLimitError({})), DefectTool: () => Effect.die("private defect details"), UntypedTool: () => Effect.void, + StructuredResultTool: () => Effect.succeed({ answer: "result" }), AnnotatedVoidTool: () => Effect.void }) @@ -258,16 +264,24 @@ describe("McpServer", () => { strictEqual(response.status, 404) })) describe("registerToolkit", () => { - it.effect("lists output schemas only for tools with typed success schemas", () => + it.effect("lists output schemas only for structured tool results", () => Effect.gen(function*() { const client = yield* makeToolkitTestClient() const result = yield* client["tools/list"]({}) - const typedTool = result.tools.find((tool) => tool.name === "OptionalStringTool") + const structuredTool = result.tools.find((tool) => tool.name === "StructuredResultTool") + const scalarTool = result.tools.find((tool) => tool.name === "OptionalStringTool") const untypedTool = result.tools.find((tool) => tool.name === "UntypedTool") const annotatedVoidTool = result.tools.find((tool) => tool.name === "AnnotatedVoidTool") - assert.deepStrictEqual(typedTool?.outputSchema, { type: "string" }) + assert.deepStrictEqual(structuredTool?.outputSchema, { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + additionalProperties: false + }) + assertTrue(scalarTool !== undefined) + assert.isFalse("outputSchema" in scalarTool) assertTrue(untypedTool !== undefined) assert.isFalse("outputSchema" in untypedTool) assertTrue(annotatedVoidTool !== undefined)