Skip to content

Count MCP connector faults as failures and log MCP transport errors #455

Description

@HMarzban

Summary

When the MCP connector itself fails, operators cannot see it. A server fault inside a tool is counted as tool-error. The admin MCP page shows it as "Refused", the same as a typo in a slug. A fault in the SDK transport leaves no reason in any log. MCP log lines also carry no request id, so one failed call cannot be traced across REST and the WS hop.

Parent: #230.

Where

  • apps/hocuspocus.server/src/modules/mcp/tools/toolContext.ts:22-27: refuse(text) throws ToolRefusal, which carries text only.
  • toolContext.ts:69-76: run maps any isError result and any ToolRefusal to outcome = 'tool-error'. Only an unexpected throw becomes 'error', and only that throw writes the "MCP tool failed" line (:78).
  • toolContext.ts:108-113: loadContent turns a failed live read into refuse("docs.plus could not read the document right now…"). All three write tools call it before they apply (tools/documentTools.ts:320, :386, :471).
  • documentTools.ts:137-147: writeResult returns toolError(...) for rejected, open-failed, persist-failed, not-confirmed, unreachable and upstream-unauthorized. All six are server faults.
  • tools/chatTools.ts:213-216: a failed Supabase insert on post_chat_message becomes toolError.
  • apps/admin-dashboard/src/pages/mcp.tsx:45-48, :65-67: tool-error shows as "Refused". Any other outcome except ok and rate-limited shows as "Failed".
  • infra/usageStore.ts:29: outcomes are counted in the Redis hash mcp:usage:<day> as <tool>:<outcome>. No MCP Prometheus metric exists.
  • http/router.ts:27: createMcpHandler(deps.factory) is called with no options.
  • http/serverFactory.ts:14-19: the comment says a missing subject "fails loudly". In practice the throw reaches the SDK's error reporter and is dropped.
  • document-content/infra/contentClient.ts:14, :126-135: read(documentId) and its hop carry no request id. apply gets one only from the REST route (document-content/http/controller.ts:170-176). The content-client error lines (:70-73, :104-107, :111-114, :117-120, :143-148, :161-164) log documentId only.
  • documentTools.ts:36-37: NOT_CONFIRMED_TEXT says "It may still be saved". That is wrong for upstream-unauthorized, because the WS side refused the service-role bearer before it applied anything (contentClient.ts:109-115).

What happens

Steps 1 to 4 are read from code, not measured.

  1. The REST and WS service-role keys stop matching, or the WS hop is down. Every tool fails at its first read, with "could not read the document right now", and the call counts as tool-error. The admin page shows "Refused", and "Failed" stays at 0. Every response is HTTP 200, because a tool result is a JSON-RPC result, so the HTTP 5xx alert never fires. The cause is logged only on the content-client side, with no link to the tool call.
  2. A connected app ships a newer MCP protocol revision, or drops a required header. The SDK answers 400 or 500. The access log says only "Request completed status 400".
  3. Two agents write at once, and one gets persist-failed. The operator cannot join the REST access line, the MCP tool line and the WS line, because each has a different id or none.
  4. Rare: the hop fails between the read and the apply of one call. For upstream-unauthorized, the agent is told to wait and read again. Nothing was saved, so it may retry in a loop.

Fix

One mechanism, in the existing files. Add no new layer.

  1. Outcome on refuse. Give refuse an optional outcome: refuse(text, outcome: 'tool-error' | 'error' = 'tool-error'). ToolRefusal carries it, and run writes it into outcome. This is the outcome argument that RFC: should a burst of connected-app writes wait instead of failing busy? #360 expects; RFC: should a burst of connected-app writes wait instead of failing busy? #360 may widen the type. For the six server arms of writeResult, the unavailable arm of loadContent and the chat insert catch, throw through refuse(text, 'error') instead of returning toolError. Do not give toolError an outcome. Keep busy and the input refusals as tool-error. run writes no extra error-level line for a refusal. The cause is already logged by the content client, by the WS "Content apply failed" line (document-content/infra/hocuspocusApply.ts:296-302) or by the chat catch (chatTools.ts:215). A second line would count twice toward the "Error surge" alert (scripts/observability/grafana/provisioning/alerting/rules-app-logs.yml:92).
  2. Copy for one case only. Give upstream-unauthorized its own text: "docs.plus could not save this write, and nothing was saved. Do not retry now. Tell the person, and try again later." Keep NOT_CONFIRMED_TEXT for unreachable, persist-failed and not-confirmed, because those writes may really have applied. Add the new text to docs/mcp/reference.md §Errors.
  3. Transport errors. Add logger to RouterDeps, and pass deps.logger to createRouter in module.ts. Write the warn line, "MCP transport error", in one function, logTransportError(logger, err, requestId?), in http/serverFactory.ts. router.ts imports it. Call it from two places:
    • createMcpHandler(deps.factory, { onerror }) in router.ts. It catches current-revision rejections and factory throws (@modelcontextprotocol/server@2.0.0, dist/index.mjs:1205-1242). A legacy factory throw also reaches it (:1026-1029).
    • server.server.onerror in serverFactory.ts. Claude and ChatGPT use the 2025 revisions today (docs/mcp/reference.md:13). That traffic takes the SDK's legacy path, whose errors reach only the per-server Protocol.onerror (dist/index.mjs:966-1031, dist/src-CX2iR2pK.mjs:6301-6340). Read from code, not measured.
    • An SDK error message can contain the whole JSON-RPC message, document text included (src-CX2iR2pK.mjs:6311). Log err.name, and err.message cut at its first { or at 200 characters. Never log err whole.
    • Correct the "fails loudly" comment in serverFactory.ts.
  4. Request id.
    • In createTransportHandler (http/controller.ts:112-118), add requestId: c.get('requestId') to authInfo.extra before handler.fetch. The requestId() middleware runs on every route (src/index.ts:39).
    • In the factory (serverFactory.ts), read authInfo.extra.requestId once, next to callerFrom. Pass it to createToolContext(deps, caller, requestId) and to the server.server.onerror line.
    • Add requestId?: string to ToolContext (types.ts), not to Caller, because Caller is the person behind the grant.
    • run adds requestId to the "MCP tool call" line.
    • The three deps.content.apply calls (documentTools.ts:322, :421, :495) pass requestId, as the REST route does. internalHop already sends it as x-request-id, and the WS app adopts it (document-content/http/internalApp.ts:40). MCP applies send no commitMessage, so the WS forceKey (document-content/http/controller.ts:203) does not change.
    • ContentClient.read becomes read(documentId, requestId?). loadContent passes the context's requestId, and readLive passes it to internalHop. The read is where most faults surface.
    • Add requestId to the content-client log lines listed in Where.
    • The router hook gets no request from the SDK, so its line has no requestId. The access log line joins it by time and status.

Out of scope

  • A new alert rule. No MCP Prometheus metric exists; the WS side counts MCP applies as document_content_apply_total{mode=~"blocks|text"}. An alert is a separate ops decision.
  • A read fallback to the stored head when the WS hop fails. No incident has been reported, and it changes read behaviour.
  • The busy wait-or-fail question: RFC: should a burst of connected-app writes wait instead of failing busy? #360.

Acceptance criteria

  • With the WS process stopped, get_outline on an owned document returns the "could not read" text. The "MCP tool call" line says outcome: 'error', and "Failed" rises by 1 on the admin MCP page.
  • The six server arms of writeResult, the unavailable arm of loadContent and the chat insert catch throw refuse(text, 'error'). conflict, invalid-content, not-found and busy still return toolError.
  • A slug typo, a bad section_id and busy still count as tool-error ("Refused").
  • upstream-unauthorized returns its own text, which says nothing was saved. docs/mcp/reference.md §Errors carries it.
  • A transport-level 400 writes one warn line on the legacy path and one on the current path. The line logs err.name and the cut message, never err.
  • For one failed call, the "MCP tool call" line and the content-client error line carry the same requestId. A WS-side line, when one exists, carries it too.
  • The [Unreleased] › Added MCP connector bullet in apps/hocuspocus.server/CHANGELOG.md says that "Failed" counts start on the deploy day. There is no Fixed entry. The 35 days already in Redis are not rewritten.

Verify

No test covers run or the outcomes today (modules/mcp/__tests__ holds only hasMedia and outline), so these checks are manual on a local stack.

  • cd apps/hocuspocus.server && bun run typecheck && bun test passes.
  • Setup: run dev:rest and dev:ws. Connect a connected app or the MCP Inspector to the local /api/mcp. A browser client needs its origin in ALLOWED_ORIGINS (API.md §MCP connector). Use its OAuth bearer for the POST steps below.
  • Fault count: stop dev:ws, then call get_outline on an owned document. Check the reply, the "MCP tool call" line (outcome: 'error') and the admin page. Check that the "Internal live read failed" line carries the same requestId.
  • Legacy transport error: POST a tools/list JSON-RPC body with a valid bearer and MCP-Protocol-Version: 1999-01-01. Expect a 400 and one warn line.
  • Current-path transport error: POST the same body with MCP-Protocol-Version: 2026-07-28 and no _meta envelope. Expect a 400 and one warn line.
  • Privacy: read logTransportError. It logs err.name and the cut err.message only, never err. A POST cannot prove this: an unknown method gets "Method not found" and calls no onerror.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DevOpsbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions