Skip to content

fix(sdk): read MCP verdicts fail-closed; complete the prompt options - #7

Merged
routeplane-ops merged 1 commit into
mainfrom
feat/fail-closed-verdicts-prompt-options
Jul 27, 2026
Merged

fix(sdk): read MCP verdicts fail-closed; complete the prompt options#7
routeplane-ops merged 1 commit into
mainfrom
feat/fail-closed-verdicts-prompt-options

Conversation

@routeplane-ops

Copy link
Copy Markdown
Collaborator

Follow-up to #6, from cross-checking against the Python SDK (routeplane-core/routeplane-python#1). Two real gaps; one reported issue that turned out not to apply here.

1. Verdict reading was fail-open

#6 returned the gateway's body verbatim. That is fine when the gateway answers as documented, and wrong the moment it doesn't:

// gateway returns {} — an empty 200, a proxy error page, a shape we changed
const verdict = await rp.mcp.authorizeToolCall({ server, tool });
if (verdict.outcome === 'deny') return;  // false
makeTheToolCall();                        // proceeds

A default-deny boundary has to survive the client, not just the gateway. Only an explicit outcome: 'allow' | 'deny' is honoured now; anything else denies (and run steps stop) with the unparsed payload preserved under response so the cause is still debuggable. Same fix in the MCP server's shared verdict() helper.

A 4xx carrying no verdict still throws. Axum rejects a malformed body with a plain-text 422; reading that as a policy deny would bury the caller's own bug. Fail-closed applies to reading a decision the gateway actually made — not to inventing one it never sent.

Verified the guard is real rather than tautological: reverting the two normalizers to the old pass-through turns exactly those 12 tests red, and the 422-still-throws case stays green.

2. Prompt options were incomplete

Checked against prompts_api.rs / crates/prompts:

  • missing ("error" | "empty", gateway default error) was unreachable — callers could not opt out of a hard failure on an unsupplied variable.
  • x-routeplane-cohort had no typed route, so sticky A/B assignment was unusable from PromptResource even though createHeaders already supported the header.
  • complete() threaded only model, so temperature, max_tokens, stream and the rest of the flattened chat body were unreachable. Now overrides.
  • variables widens from Record<string, string> to any JSON, matching the gateway's BTreeMap<String, Value>.

All additive — existing call sites are unchanged.

3. Not applicable here

The Python SDK was sending provider in the completions body, where the gateway flattens it into a chat request that ignores unknown fields — a silent no-op. TypeScript was already correct, sending x-routeplane-provider as a header. Added a regression test pinning that, plus a doc note on overrides explaining why routing options must not go in the body.

Verification

pnpm build, pnpm test, pnpm lint pass. 77 tests, up from 61: 16 new covering the fail-closed matrix across all four verdict methods, the 422-throws boundary, the payload preservation, and the three prompt-option paths.

Two follow-ups from cross-checking the TypeScript SDK against the Python one.

Verdict reading was fail-open. The verdict methods returned the gateway's body
verbatim, so `if (verdict.outcome === 'deny')` was false for an empty 200, an
unknown outcome value, or a proxy error page — and the caller proceeded with the
tool call. A default-deny policy boundary has to survive the client, not just
the gateway: only an explicit allow is now an allow, and anything unparseable
denies (run steps stop) with the payload preserved under `response` so the cause
stays debuggable. Same change in the MCP server's shared helper.

A 4xx carrying no verdict still throws rather than reading as a deny. Axum
rejects a malformed body with a plain-text 422, and turning the caller's own bug
into a policy refusal would bury it. Fail-closed applies to reading a decision
the gateway actually made, not to inventing one it never sent.

Prompt options were incomplete. `missing` ("error" | "empty") was unreachable,
so callers could not opt out of the default hard failure on an unsupplied
variable; the `x-routeplane-cohort` header had no typed route, leaving sticky
A/B assignment unusable; and `complete()` threaded only `model`, so temperature,
max_tokens and the rest of the chat body were unreachable. `variables` widens to
any JSON, matching the gateway. All additive — existing call sites are
unchanged.

Verified the fail-closed tests fail against the old pass-through: reverting the
normalizers turns exactly those 12 red, and the 422-still-throws case stays
green.
@routeplane-ops
routeplane-ops merged commit c20766f into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant