Skip to content

MCP refusals have no status word: ~400 validation bails return a bare sentence, and seven PostgreSQL refusals now answer HTTP 500 on the web where they answered 200 before #3719 (Q11 residue) #3739

Closed

Description

@erikdarlingdata

The lie

#3719 made every caught exception on both SKUs answer with one wire shape — McpHelpers.FormatError returns {"status":"error","message":"Error during get_x: …","hints":{"operation":"get_x"}} — and the payload contract now says "errors one shape". But a refusal is not an error, and the refusals never got a shape at all. Roughly four hundred validation sites on both SKUs still return the validator's bare sentence as the whole tool result: the ServerResolver bails ("Could not resolve server …", ~190 sites) and the ValidateHoursBack / ValidateTop / ValidateLimit / "Missing required parameter" / "No plan XML provided." sentences (~210 sites), all of the if (error != null) return error; idiom. A client parsing the result gets a string where every other outcome is JSON, and nothing in it says whether the tool failed, refused, or found nothing.

Seven PostgreSQL refusals took a different path and landed worse. They hand-build the envelope with McpHelpers.Status("error", …), and since #3719 the web surface reads status:"error" as a server fault:

  • get_pg_deadlocks — limit (Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDeadlockTools.cs:38)
  • get_pg_deadlock_detail — limit (DarlingMcpPgDeadlockTools.cs:132)
  • get_pg_log_events — limit (DarlingMcpPgLogEventTools.cs:46), family (:53), min_severity (:65)
  • get_pg_server_config — limit (DarlingMcpPgServerStateTools.cs:615)
  • get_pg_server_config_changes — limit (DarlingMcpPgServerStateTools.cs:716)

Before #3719, DarlingWebEndpoints.ClassifyToolResponse passed anything starting with { through as HTTP 200, so a bad limit on /api/read/get_pg_deadlocks was a 200 carrying {status:"error"}. Now (Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs:2776-2795) McpHelpers.IsErrorEnvelope is tested first and maps to ToolResponseKind.ServerError → 500. A client-correctable refusal that answered 200 yesterday answers 500 today — a wire regression for the dashboard's consumers, and the wrong code twice over: neither 200 nor 500 is the 400 a refusal deserves. #3719 stated this consequence in its body and pinned the present behaviour (DarlingWebEndpointsTests.ClassifyToolResponse_TheErrorEnvelope_IsServerError) so a ruling changes it knowingly; this issue is that ruling's home.

The bare-sentence refusals, meanwhile, happen to map to 400 on the web (ClassifyToolResponse's final arm: not JSON, not Error during → ClientError), which is the right HTTP code — but only by the accident of being unshaped. Shaping them as status:"error" without a ruling would move all ~400 of them to 500 too.

What is verified at source (origin/dev 98d52fe)

  • PerformanceMonitor.Common/Mcp/McpHelpers.cs:507 FormatError, :537 IsErrorEnvelope, :598 Status(status, message, hints).
  • DarlingWebEndpoints.cs:2776-2795 ClassifyToolResponse: envelope → ServerError; JSON → passthrough; Error during → ServerError; else ClientError.
  • The seven Status("error", …) refusal sites listed above (git grep 'Status("error"' -- 'Darling/**/Mcp/DarlingMcpPg*.cs' returns exactly these seven).
  • MCP errors take one wire shape: FormatError returns the {status:"error"} envelope on both SKUs (Partial #3653, Q11) #3719's body, "Validation refusals" paragraph: "the fix needs a ruling (which status word — error collides with the web 400/500 split; invalid already means a write body that would not parse)".

The fix shape (after the ruling)

  1. Ruling: one status word for a refusal. error is taken (fault → 500) and invalid is taken (a write body that would not parse). The candidates are a new word (refused / rejected) or widening invalid to "the request as given cannot be served" with the write-body case as one instance of it. Whatever the word, ClassifyToolResponse grows an arm that maps it to 400, ahead of the envelope arm.
  2. The seven: change Status("error", …) to the ruled word at the seven sites — that alone restores them from 500 to a correct 400.
  3. The ~400: ServerResolver's miss and the Validate* helpers build the sentence today; the shape change belongs in those few producers (a McpHelpers.Refusal(operation, sentence) twin of FormatError), not at four hundred call sites. McpPayloadContractCensusTests gets the arm: a refusal returns through the shared shape, and the census that already classifies every catch site classifies every return error; site too.
  4. wwwroot/js/util.js classifyResponse and DarlingTriageEndpoint learn the word (both learned status:"error" in MCP errors take one wire shape: FormatError returns the {status:"error"} envelope on both SKUs (Partial #3653, Q11) #3719 — same two sites).
  5. Tool descriptions: MCP errors take one wire shape: FormatError returns the {status:"error"} envelope on both SKUs (Partial #3653, Q11) #3719's Q11 ruling asked for the wire change to be noted in the descriptions; the refusal word gets the same sentence.

What this does NOT do

It does not touch the four miss words (empty / not_found / …, #3703's lane) — a refusal is a fourth kind of outcome beside success, miss and fault, not a miss.

Descends from #3719 (Q11 of #3653). Partial #3653 residue.

Activity

  1. added a commit that references this issue on Sep 20, 2026
    b458b18
  2. erikdarlingdata commented on Sep 20, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by #3776 (merged to dev).

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions