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..eb2be12dc1b 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -884,10 +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), + ...(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 df4c376e326..f81383d91f7 100644 --- a/packages/effect/test/unstable/ai/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer.test.ts @@ -42,14 +42,35 @@ const DefectTool = Tool.make("DefectTool", { success: Schema.String }) -const TestToolkit = Toolkit.make(OptionalStringTool, PublicFailureTool, InternalAiErrorTool, 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" }) +}) + +const TestToolkit = Toolkit.make( + OptionalStringTool, + PublicFailureTool, + InternalAiErrorTool, + DefectTool, + UntypedTool, + StructuredResultTool, + AnnotatedVoidTool +) 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, + StructuredResultTool: () => Effect.succeed({ answer: "result" }), + AnnotatedVoidTool: () => Effect.void }) const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." @@ -243,6 +264,30 @@ describe("McpServer", () => { strictEqual(response.status, 404) })) describe("registerToolkit", () => { + it.effect("lists output schemas only for structured tool results", () => + Effect.gen(function*() { + const client = yield* makeToolkitTestClient() + + const result = yield* client["tools/list"]({}) + 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(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) + assert.isFalse("outputSchema" in annotatedVoidTool) + })) + it.effect("returns concise parameter-validation errors without invoking the handler", () => Effect.gen(function*() { let handlerInvoked = false