Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-tool-output-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Include typed tool output schemas in MCP `tools/list` responses.
4 changes: 4 additions & 0 deletions packages/effect/src/unstable/ai/McpSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,10 @@ export class Tool extends Schema.Class<Tool>(
* 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.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/effect/src/unstable/ai/McpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,10 +884,12 @@ export const registerToolkit: <Tools extends Record<string, Tool.Any>>(
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 })),
Expand Down
49 changes: 47 additions & 2 deletions packages/effect/test/unstable/ai/McpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Toolkit.Tools<typeof TestToolkit>>

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."
Expand Down Expand Up @@ -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
Expand Down
Loading