diff --git a/apps/ai/src/mcp/dispatcher.test.ts b/apps/ai/src/mcp/dispatcher.test.ts index ee8cfe55cf..9edf9b2ffc 100644 --- a/apps/ai/src/mcp/dispatcher.test.ts +++ b/apps/ai/src/mcp/dispatcher.test.ts @@ -4,7 +4,7 @@ import type { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import { ActorId } from "@maple/domain/primitives" import { McpToolExecutor } from "./dispatcher" import { listMcpTools } from "./tool-catalog" -import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "./expected-failures" +import { MCP_ANTICIPATED_ERROR_IDENTIFIERS, recordExpectedMcpFailure } from "./expected-failures" import { mapleToolCatalog, mapleToolCatalogFor, toInputSchema, toOutputSchema } from "./tools/registry" import type { McpToolRuntimeRequirements } from "./tools/runtime-requirements" import type { TenantContext } from "@maple/backend/services/auth/tenant-context" @@ -256,6 +256,24 @@ describe("MCP dispatcher", () => { }), ) + // A failure message can quote a whole SQL statement; the span keeps a bounded prefix. + it.effect("caps the failure message it puts on the span", () => + Effect.gen(function* () { + const { spans, tracer } = makeRecordingTracer() + const message = `SQL rejected: ${"x".repeat(5_000)}` + yield* recordExpectedMcpFailure( + { _tag: "@maple/mcp/errors/McpInvalidInputError", message }, + "Invalid tool input", + ).pipe(Effect.withSpan("probe"), Effect.withTracer(tracer)) + + const recorded = String( + spans.find((s) => s.name === "probe")?.attributes.get("maple.mcp.error.message"), + ) + expect(recorded.length).toBeLessThan(message.length) + expect(message.startsWith(recorded)).toBe(true) + }), + ) + // Expected 4xx (bad parameters, bad credentials) must not be Error spans — // only 5xx is, per CLAUDE.md. Prod was recording `@maple/mcp/decode-error` // and `McpAuthInvalidError` as Error status + exception events. @@ -270,6 +288,7 @@ describe("MCP dispatcher", () => { assert.isDefined(dispatchSpan) expect(dispatchSpan.attributes.get("error.type")).toBe("@maple/mcp/errors/McpDecodeError") expect(dispatchSpan.attributes.get("http.response.status_code")).toBe(400) + expect(dispatchSpan.attributes.get("maple.mcp.error.message")).toContain("inspect_trace") // The inner registry span still FAILS with the decode error (the typed // error stays in the error channel); the SDK exporter is what turns it diff --git a/apps/ai/src/mcp/expected-failures.ts b/apps/ai/src/mcp/expected-failures.ts index e88dc25cd8..adaa56e1af 100644 --- a/apps/ai/src/mcp/expected-failures.ts +++ b/apps/ai/src/mcp/expected-failures.ts @@ -37,6 +37,8 @@ const expectedStatusByTag = new Map(Object.entries(MCP_EXPECTED_ /** Spread into `anticipatedErrorIdentifiers` next to `ANTICIPATED_ERROR_IDENTIFIERS`. */ export const MCP_ANTICIPATED_ERROR_IDENTIFIERS: ReadonlyArray = [...expectedStatusByTag.keys()] +const MAX_SPAN_MESSAGE_CHARS = 500 + /** * Record an expected MCP 4xx on the current span and log it at Warn. * @@ -54,6 +56,8 @@ export const recordExpectedMcpFailure = ( return Effect.annotateCurrentSpan({ "error.type": error._tag, "http.response.status_code": status, + // On the span too, so a tool's failure reasons are one query rather than a join to logs. + "maple.mcp.error.message": error.message.slice(0, MAX_SPAN_MESSAGE_CHARS), }).pipe( Effect.andThen( Effect.logWarning(logMessage).pipe(