From 55e29935b6b651b0c826dcfb2652353783831683 Mon Sep 17 00:00:00 2001 From: Edward Bramanti Date: Fri, 28 Aug 2026 13:33:11 -0600 Subject: [PATCH] Report MCP toolkit failures before recovery --- .changeset/report-mcp-tool-failures.md | 5 ++ packages/effect/src/unstable/ai/McpServer.ts | 7 +- .../unstable/ai/McpServer/McpServer.test.ts | 67 +++++++++++++++++-- 3 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 .changeset/report-mcp-tool-failures.md diff --git a/.changeset/report-mcp-tool-failures.md b/.changeset/report-mcp-tool-failures.md new file mode 100644 index 00000000000..06ff5946ebb --- /dev/null +++ b/.changeset/report-mcp-tool-failures.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +McpServer now reports toolkit failures through configured `ErrorReporter`s before converting them into MCP tool error results. diff --git a/packages/effect/src/unstable/ai/McpServer.ts b/packages/effect/src/unstable/ai/McpServer.ts index e5ada2adde6..212dd9d2598 100644 --- a/packages/effect/src/unstable/ai/McpServer.ts +++ b/packages/effect/src/unstable/ai/McpServer.ts @@ -1573,9 +1573,6 @@ export const registerToolkit: >( Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), - Effect.provideContext( - services as Context.Context> - ), Effect.map((result) => new CallToolResult({ isError: false, @@ -1586,6 +1583,10 @@ export const registerToolkit: >( }] }) ), + Effect.withErrorReporting, + Effect.provideContext( + services as Context.Context> + ), Effect.tapCause(Effect.logError), Effect.catch((error) => { if (AiError.isAiError(error)) { diff --git a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts index 89844e5b973..b70cd503f10 100644 --- a/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts +++ b/packages/effect/test/unstable/ai/McpServer/McpServer.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest" import { assertTrue, strictEqual } from "@effect/vitest/utils" +import * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" +import * as ErrorReporter from "effect/ErrorReporter" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Queue from "effect/Queue" @@ -45,6 +47,10 @@ const DefectTool = Tool.make("DefectTool", { success: Schema.String }) +const UnserializableResultTool = Tool.make("UnserializableResultTool", { + success: Schema.Unknown +}) + const UntypedTool = Tool.make("UntypedTool") const StructuredResultTool = Tool.make("StructuredResultTool", { @@ -68,6 +74,7 @@ const TestToolkit = Toolkit.make( PublicFailureTool, InternalAiErrorTool, DefectTool, + UnserializableResultTool, UntypedTool, StructuredResultTool, AnnotatedVoidTool, @@ -76,16 +83,20 @@ const TestToolkit = Toolkit.make( ) type TestToolkitHandlers = Toolkit.HandlersFrom> +const publicFailure = new Error("Public failure") +const privateDefect = new Error("private defect details") + const testToolkitHandlers = TestToolkit.of({ OptionalStringTool: ({ signature }) => Effect.succeed(signature ?? "omitted"), - PublicFailureTool: () => Effect.fail(new Error("Public failure")), + PublicFailureTool: () => Effect.fail(publicFailure), InternalAiErrorTool: () => Effect.fail(new AiError.RateLimitError({})), - DefectTool: () => Effect.die("private defect details"), + DefectTool: () => Effect.die(privateDefect), UntypedTool: () => Effect.void, StructuredResultTool: () => Effect.succeed({ answer: "result" }), AnnotatedVoidTool: () => Effect.void, NullableResultTool: () => Effect.succeed(null), - ArrayResultTool: () => Effect.succeed(["first", "second"]) + ArrayResultTool: () => Effect.succeed(["first", "second"]), + UnserializableResultTool: () => Effect.succeed(1n) }) const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error." @@ -153,10 +164,14 @@ const makeRouterTestClient = ( router: Layer.Layer ) => makeTestClientWith(TestServerLayer, { routerLayer: router }) -const makeToolkitTestClient = Effect.fnUntraced(function*(handlers: TestToolkitHandlers = testToolkitHandlers) { +const makeToolkitTestClient = Effect.fnUntraced(function*( + handlers: TestToolkitHandlers = testToolkitHandlers, + reporterLayer: Layer.Layer = Layer.empty +) { const serverLayer = McpServer.toolkit(TestToolkit).pipe( Layer.provideMerge(TestToolkit.toLayer(handlers)), - Layer.provide(TestServerLayer) + Layer.provide(TestServerLayer), + Layer.provide(reporterLayer) ) const { client } = yield* makeTestClientWith(serverLayer) yield* client.initialize({ @@ -485,6 +500,48 @@ describe("McpServer", () => { assert.strictEqual(text, INTERNAL_TOOL_ERROR_MESSAGE) })) + it.effect("reports tool failures before converting them to public results", () => + Effect.gen(function*() { + const reported: Array> = [] + const reporter = ErrorReporter.make(({ cause }) => { + reported.push(cause) + }) + const client = yield* makeToolkitTestClient( + testToolkitHandlers, + ErrorReporter.layer([reporter]) + ) + + const success = yield* client["tools/call"]({ + name: "UntypedTool", + arguments: {} + }) + assert.strictEqual(success.isError, false) + assert.lengthOf(reported, 0) + + const publicFailureResult = yield* client["tools/call"]({ + name: "PublicFailureTool", + arguments: {} + }) + assert.strictEqual(toolResultText(publicFailureResult), "Public failure") + + const defectResult = yield* client["tools/call"]({ + name: "DefectTool", + arguments: {} + }) + assert.strictEqual(toolResultText(defectResult), INTERNAL_TOOL_ERROR_MESSAGE) + + const serializationResult = yield* client["tools/call"]({ + name: "UnserializableResultTool", + arguments: {} + }) + assert.strictEqual(toolResultText(serializationResult), INTERNAL_TOOL_ERROR_MESSAGE) + + assert.lengthOf(reported, 3) + assert.strictEqual(Cause.squash(reported[0]), publicFailure) + assert.strictEqual(Cause.squash(reported[1]), privateDefect) + assert.instanceOf(Cause.squash(reported[2]), TypeError) + })) + it.effect("keeps unknown tools as protocol errors", () => Effect.gen(function*() { const client = yield* makeToolkitTestClient()