Skip to content
Open
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/report-mcp-tool-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

McpServer now reports toolkit failures through configured `ErrorReporter`s before converting them into MCP tool error results.
7 changes: 4 additions & 3 deletions packages/effect/src/unstable/ai/McpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,9 +1573,6 @@ export const registerToolkit: <Tools extends Record<string, Tool.Any>>(
Stream.unwrap,
Stream.run(Sink.last()),
Effect.flatMap(Effect.fromOption),
Effect.provideContext(
services as Context.Context<Tool.HandlerServices<Tools[keyof Tools]>>
),
Effect.map((result) =>
new CallToolResult({
isError: false,
Expand All @@ -1586,6 +1583,10 @@ export const registerToolkit: <Tools extends Record<string, Tool.Any>>(
}]
})
),
Effect.withErrorReporting,
Effect.provideContext(
services as Context.Context<Tool.HandlerServices<Tools[keyof Tools]>>
),
Effect.tapCause(Effect.logError),
Effect.catch((error) => {
if (AiError.isAiError(error)) {
Expand Down
67 changes: 62 additions & 5 deletions packages/effect/test/unstable/ai/McpServer/McpServer.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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", {
Expand All @@ -68,6 +74,7 @@ const TestToolkit = Toolkit.make(
PublicFailureTool,
InternalAiErrorTool,
DefectTool,
UnserializableResultTool,
UntypedTool,
StructuredResultTool,
AnnotatedVoidTool,
Expand All @@ -76,16 +83,20 @@ const TestToolkit = Toolkit.make(
)
type TestToolkitHandlers = Toolkit.HandlersFrom<Toolkit.Tools<typeof TestToolkit>>

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."
Expand Down Expand Up @@ -153,10 +164,14 @@ const makeRouterTestClient = (
router: Layer.Layer<never, never, HttpRouter.HttpRouter>
) => makeTestClientWith(TestServerLayer, { routerLayer: router })

const makeToolkitTestClient = Effect.fnUntraced(function*(handlers: TestToolkitHandlers = testToolkitHandlers) {
const makeToolkitTestClient = Effect.fnUntraced(function*(
handlers: TestToolkitHandlers = testToolkitHandlers,
reporterLayer: Layer.Layer<never> = 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({
Expand Down Expand Up @@ -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<Cause.Cause<unknown>> = []
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()
Expand Down