Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8f6b6c1
feat(ai): record expected MCP failure messages on the dispatcher span
Makisuo Oct 30, 2026
35a5633
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
43a154e
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
3fca428
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
4b123e8
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
4710390
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
fb08629
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
d130b0b
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
c2db500
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
b347b30
test(ai): pin the cap on the failure message recorded on the span
Makisuo Oct 30, 2026
9e0a416
test(ai): read the recorded span message as a string without a type a…
Makisuo Oct 30, 2026
9f9c473
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
0eafacb
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
bb10186
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
4139263
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
ba6dae8
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
9a7c367
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
7d6b523
Merge branch 'perf/run-sql-trace-seek-row-cap' into feat/mcp-failure-…
Makisuo Oct 30, 2026
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
21 changes: 20 additions & 1 deletion apps/ai/src/mcp/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/ai/src/mcp/expected-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const expectedStatusByTag = new Map<string, number>(Object.entries(MCP_EXPECTED_
/** Spread into `anticipatedErrorIdentifiers` next to `ANTICIPATED_ERROR_IDENTIFIERS`. */
export const MCP_ANTICIPATED_ERROR_IDENTIFIERS: ReadonlyArray<string> = [...expectedStatusByTag.keys()]

const MAX_SPAN_MESSAGE_CHARS = 500

/**
* Record an expected MCP 4xx on the current span and log it at Warn.
*
Expand All @@ -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(
Expand Down
Loading