Skip to content

fix(mcp): acknowledge every JSON-RPC notification instead of answering 400 - #706

Merged
dash0-dev[bot] merged 2 commits into
mainfrom
weekly-improve/2026-10-19-ack-mcp-notifications
Oct 19, 2026
Merged

dash0-dev[bot] merged 2 commits into
mainfrom
weekly-improve/2026-10-19-ack-mcp-notifications

Conversation

@dash0-dev

@dash0-dev dash0-dev Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The hosted MCP endpoint answers every JSON-RPC notification except notifications/initialized with HTTP 400 / JSON-RPC -32601 Method not found. MCP clients send notifications/cancelled whenever they abandon a request, so every MCP user sees spurious 400s, and about 11% of LoreKit's production MCP requests are counted as errors. This change acknowledges every notifications/* method with the same empty 204 that notifications/initialized already gets, and does not mark the span as an error.

Evidence (production telemetry)

  • Signal: share of mcp SERVER spans with error.type=MethodNotFound, deployment.environment.name=production: 11.15% now (780.4 of 7,001 spans over 7d, evaluated at 2026-10-19T06:01:39Z) vs 1.77% over the 21-day baseline 2026-09-21..10-12 (929 spans). The baseline undercounts, because error.type was only introduced on 2026-10-03 (fix(edge-otel): bounded DB span names, parameterised db.query.text, http.route + error.type #682). A count over the baseline window that does not depend on error.type found 2,729 notifications/cancelled 400s (5.2% of MCP requests), so the problem is ongoing, not new. In 2026-10-12T06:01:39Z..2026-10-19T06:01:39Z, 749 notifications/cancelled requests got HTTP 400 MethodNotFound: notifications/cancelled (10.7% of MCP requests).
  • Query:
sum(increase({otel_metric_name="dash0.spans", service_name="api", deployment_environment_name="production", faas_name="mcp", otel_span_kind="SERVER", error_type="MethodNotFound"}[7d])) / sum(increase({otel_metric_name="dash0.spans", service_name="api", deployment_environment_name="production", faas_name="mcp", otel_span_kind="SERVER"}[7d])) * 100
  • Examples: trace c108e0d0554a5fc882c991c4b34bedbf, span 46f9d528d9b2a1ae: a notifications/cancelled request answered 400 after 244 ms and 3 DB child calls (auth, plan and rate-limit preamble).

Root cause

handleMcp acknowledges only one notification, by exact name:

if (method === 'notifications/initialized') {
return new Response(null, { status: 204 });
}

Every other notification (notifications/cancelled, notifications/progress, notifications/roots/list_changed, …) matches none of the handled methods, so it reaches the unknown-method fallthrough. That fallthrough calls span.clientError("MethodNotFound: …") (which records error.type=MethodNotFound) and returns a 400 JSON-RPC error body:

// Unknown method — client sent an unsupported JSON-RPC method, not a server fault.
span.clientError(`MethodNotFound: ${method}`);
return jsonrpcError(id, -32601, `Method not found: ${method}`);

Under JSON-RPC 2.0, a notification (a message without an id) never gets a response body. MCP's HTTP transport accepts one with an empty 2xx. The CLI's local stdio MCP server never had this defect, because it already acknowledges by prefix:

// Notifications (initialized, cancelled, …) never get a response.
if (typeof method === 'string' && method.startsWith('notifications/')) {
return null;
}

Change

  • supabase/functions/mcp/mcp-handler.ts: the exact method === 'notifications/initialized' check is now typeof method === 'string' && method.startsWith('notifications/'). The typeof guard (added in review, the same guard the CLI stdio server uses) keeps a non-string method such as 42 on the -32601 path instead of throwing a TypeError (500). It keeps the same position, before tools/list and tools/call and well before the fallthrough, and it still returns new Response(null, { status: 204 }) without calling clientError/error. The span keeps mcp.method (already set above the branch), so notifications stay visible in telemetry, but they no longer carry error.type.
  • Non-notification unknown methods (resources/list, server/discover, resources/templates/list, …) still reach the fallthrough and still get -32601.
  • Why 204 and not 202: MCP's Streamable HTTP transport (2025-03-26 and later) describes a 202 Accepted with no body. This server negotiates protocol 2024-11-05. 204 is what clients already receive for notifications/initialized from this endpoint on every session, so keeping 204 means no behaviour change for the one notification that already worked. It also keeps the diff to one condition, and keeps the existing mcp/index.ts comment ("notifications/initialized answers 204") true without touching that file. Both statuses are an empty 2xx, which is what a notification sender expects. Moving every notification to 202 together can be a separate, deliberate change.
  • Not changed: auth, rate limiting, supabase/functions/mcp/index.ts (a notification still pays the auth + plan + rate-limit preamble, as initialized does today), and the CLI stdio server, which is already correct.
  • Docs: none applied. This is a protocol-compliance fix, and no user-facing doc or llms.txt describes how notifications are answered. docs/mcp-tools.md lists -32601 as "Unknown method or tool name", which stays accurate.

Test

  • New source-scan spec, packages/mcp-core/src/mcp-guards/mcp-notification-ack.spec.ts (runs in CI's check job via nx test mcp-core; vitest cannot import the Deno handler, which is why mcp-method-guard-ordering.spec.ts / mcp-authz-status.spec.ts are also source scans). It asserts that handleMcp acknowledges by the notifications/ prefix and not by the exact initialized name, that the prefix test is typeof-guarded, that the branch answers an empty 204, that it calls none of clientError( / .error( / jsonrpcError(, that it comes before the MethodNotFound fallthrough (so notifications/cancelled cannot reach it), and that the fallthrough still returns -32601.
    • RED against unchanged mcp-handler.ts (the original five tests): pnpm exec vitest run --root packages/mcp-core src/mcp-guards/mcp-notification-ack.spec.ts gave 4 failed / 1 passed (the passing one is the "non-notification still -32601" guard).
    • GREEN after the fix: 6/6. The sixth test, added in review, pins the typeof guard: it fails with the guard removed and passes with it. All of src/mcp-guards/ passes, and the full nx test mcp-core passes (86 files, 1991 passed, 1 skipped).
  • Behavioural proof, run locally only (not committed, because no CI job runs deno test): a Deno test imported the real handleMcp, called it with a fake span / service auth / hosted adapter, and checked notifications/{initialized,cancelled,progress,roots/list_changed} and resources/list, server/discover, resources/templates/list. Before the fix: 4 passed / 3 failed (cancelled, progress and roots/list_changed got 400). After: 7/7 passed. Each notification gets an empty-body 204 with no span-error call; each unknown request still gets 400 / -32601 with exactly one clientError("MethodNotFound: …").
  • Live smoke, scripts/smoke/smoke-mcp-tools.mjs: a new conformance check sends notifications/cancelled to the hosted endpoint and asserts a 2xx with an empty body. Only CI confirms this one. It runs in deploy.yml's "Smoke — hosted MCP tool surface against preview" step after merge (and on a manual preview redeploy of a PR). It is not part of ci.yml's Integration job, whose MCP smoke (smoke-mcp-stdio.mjs) goes through the CLI stdio server, and that server answers notifications locally without forwarding them. Locally: node --check passes, and packages/cli/test/cli-smoke.test.mjs (the static smoke-coverage guard) passes 42/42.
  • Other verification: node scripts/ci/deno-check-functions.mjs --node-modules-dir=none reports 0 errors for mcp (baseline 0). nx typecheck mcp-core and nx lint mcp-core pass (0 errors; warnings are pre-existing and none are in the new file). eslint on mcp-handler.ts is clean. supabase node --test passes 10/10 and evals node --test passes 250/250. nx affected also lists web / web-e2e (through the project graph only, since no file there changed). Their typecheck/test was not run locally because the sandbox ran out of memory, so CI's check job is the confirmation there.

How we will know it worked

Re-run the PromQL above (unit: % of production mcp SERVER spans with error.type=MethodNotFound, 7d window). Expected direction: down, from 11.15% to roughly the residual non-notification unknown methods (server/discover ~17, resources/list ~7, resources/templates/list ~7 per week, about 0.4%). The weekly loop will re-measure on or after 2026-10-26, once this has been deployed to production for a full 7-day window.

Opened by the weekly Agent0 telemetry-driven improvement automation. It is merged automatically when the review loop converges with an approving verdict and CI is green (and, for a web UI change, when the ui-verify test passes on this PR's Vercel preview), unless it carries the needs-human-merge label.

…g 400

handleMcp acknowledged only `notifications/initialized` (204); every other
notification fell through to the unknown-method branch and answered
HTTP 400 / -32601 with error.type=MethodNotFound on the request span.
MCP clients send `notifications/cancelled` whenever they abandon a
request, so ~11% of production MCP requests were recorded as errors.

Acknowledge any `notifications/*` method with the same empty 204, without
marking the span. Non-notification unknown methods still get -32601.

Pinned by mcp-notification-ack.spec.ts (source scan) and a live check in
smoke-mcp-tools.mjs.

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@dash0-dev dash0-dev Bot added the agent0-weekly-improvement Opened by the weekly Agent0 telemetry-driven improvement automation label Oct 19, 2026
@dash0-dev

dash0-dev Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ No issues found

No code changes since 53056a8, so this pass re-checked the gates only. The PR body now describes the typeof guard and the six-test spec, which clears the one prior warning; CI is green and the prior thread is resolved.

Checked: 0 of 1 dependent file traced

Progress: open review threads 1 → 0 → 0 across the last 3 reviews

What this change reaches — 1 changed export · 1 dependent file · 0 checked · 1 not checked
flowchart LR
  subgraph pr["Changed in this PR"]
    s1["handleMcp<br/>body changed"]:::changed
  end
  s1c1["supabase/functions/mcp/index.ts<br/>? not checked"]:::unknown
  s1 -.-> s1c1
  classDef changed fill:#eef2ff,stroke:#6366f1,color:#1e1b4b
  classDef ok fill:#e7f6ec,stroke:#16a34a,color:#14532d
  classDef partial fill:#fef9c3,stroke:#ca8a04,color:#713f12
  classDef unknown fill:#f3f4f6,stroke:#9ca3af,stroke-dasharray:4 3,color:#374151
  classDef bad fill:#fde8e8,stroke:#dc2626,color:#7f1d1d
  classDef warn fill:#fff4e5,stroke:#d97706,color:#78350f
  classDef imports fill:#f8fafc,stroke:#64748b,stroke-dasharray:4 3,color:#1e293b
Loading
  • handleMcp (supabase/functions/mcp/mcp-handler.ts) — body change · 1 consumer file · 1 not traced
Review details
Gate Status Details
Description vs. code ✅ Body now matches all three files: the typeof-guarded prefix check, six spec tests, the smoke check.
Prior review feedback ✅ Earlier review comments are resolved.
Documentation ✅ Handler comment explains the prefix ack and typeof guard; PR body covers cause, 204-vs-202 and tests.
Self-review signals ✅ No debug logs, leftover TODOs, or unreviewed stubs.
Code review ✅ The multi-lens review found no blocking issues.

Found

Quality — produced 0 → posted inline 0 · cleared 0 · carried forward 0 · deferred 0 · below-bar 0

Run

incremental · 0 lines in delta · tier deep · depth checkout
⚠️ Routing labelled this run tier deep via D12/D13, computed from PR totals (112 lines, 1 new file) on a zero delta; no finder, lens or verifier ran, gates only.
Integrations — none
Memories — skipped (zero-delta fast path)

Nothing to report — standards (skipped), optimality (skipped), measurability (skipped), severity, 0 files skipped.

pr-reviewer · commit 53056a8 · no code changes since 53056a8, gate checks only · how these findings are produced · updated Oct 19, 2026 8:14pm UTC

Comment thread supabase/functions/mcp/mcp-handler.ts Outdated
…ethod

Addresses the pr-reviewer bot's comment: #706 (comment)
Refs: #706

Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

The dashboard preview for this PR — redeployed on each push that changes the web app.

Project Deployment Actions Updated
lorekit Ready Ready Preview Oct 19, 2026 8:03pm UTC

Preview always points at this PR's latest commit; Deployment is the immutable build of 53056a8. · Comment /web-preview to force a redeploy. · Workflow logs

@dash0-dev
dash0-dev Bot marked this pull request as ready for review October 19, 2026 20:16
@dash0-dev
dash0-dev Bot requested a review from mthines as a code owner October 19, 2026 20:16
@dash0-dev
dash0-dev Bot merged commit 6c1a4e7 into main Oct 19, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent0-weekly-improvement Opened by the weekly Agent0 telemetry-driven improvement automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant