Skip to content

fix(authz): accept JSON-RPC responses to server-initiated requests - #6521

Open
jstar0 wants to merge 3 commits into
stacklok:mainfrom
jstar0:fix/5009-authz-accepts-client-responses
Open

fix(authz): accept JSON-RPC responses to server-initiated requests#6521
jstar0 wants to merge 3 commits into
stacklok:mainfrom
jstar0:fix/5009-authz-accepts-client-responses

Conversation

@jstar0

@jstar0 jstar0 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5009.

@eleftherias — picking this up at last. The diagnosis moved since the issue was filed, so the fix is not where the issue pointed.

Where it actually is now

The issue named pkg/transport/proxy/streamable/ and expected a response-vs-request branch in the POST handler. That branch exists on main today: handleNotificationOrClientResponse matches isClientResponse(msg) and answers 202 Accepted. The transport is spec-correct.

But the reported error string — Invalid or malformed MCP request — does not appear in the transport at all. It lives in pkg/authz/middleware.go, and the request never reaches the transport when authorization is enabled:

  • parseMCPRequest handles only *jsonrpc2.Request and returns nil for a response ("Response or error messages are not parsed here").
  • The authz middleware sees parsedRequest == nil and cannot tell that apart from a body that failed to parse, so it takes the malformed-body branch and writes the 400.

So the symptom survives exactly as reported, one layer earlier than expected. A failing test first, on unmodified main:

--- FAIL: TestMiddlewareAcceptsClientResponse/error_response
    Error:      Not equal:
                expected: 202
                actual  : 400
    Messages:   a client response must reach the transport

The change

ParsingMiddleware now records in the context when a body decoded as a JSON-RPC response or error, and the authz middleware passes those through instead of rejecting them. A response names no method and reaches no tool, so there is nothing for a policy to decide.

I was careful to keep this narrow, because the branch it touches is load-bearing for security:

  • The non-JSON-content-type refusal ahead of it is untouched. A JSON-RPC body smuggled under text/plain is still refused before parsing, and TestMiddlewareRejectsNonJSONPost and TestMiddlewareNonJSONPostVariants still pass unchanged.
  • A genuinely malformed JSON body still gets the 400 — the new path requires jsonrpc2.DecodeMessage to succeed and yield a *jsonrpc2.Response.
  • Batch rejection (Close the authz/audit blind spot for batch JSON-RPC requests #5745) runs earlier in ParsingMiddleware and is unaffected.

The new test covers both shapes a client can send back, a result and an error.

Verification

go test ./pkg/authz/... ./pkg/mcp/... ./pkg/audit/... ./pkg/transport/proxy/streamable/... all pass; golangci-lint run ./pkg/authz/... ./pkg/mcp/... reports 0 issues.

One thing worth a maintainer's eye: this makes authz pass a response through without any policy evaluation. That is the only coherent behaviour I can see — there is no method, no tool, and no resource to authorize against, and the alternative is the session teardown in the issue — but if you'd rather gate it (say, only when a matching server-initiated request is pending for that session), that correlation state does not exist yet and would be a larger change. Happy to go that way if you prefer it.

Issue stacklok#6497 reported a healthy Tableau MCP backend being marked unavailable
because the vMCP health probe used a bare HTTP GET, which that backend
rejects with 400 for lack of a session id. Current main no longer probes
with GET: the health check is BackendClient.ListCapabilities, and
newStreamableHTTPClient enables transport.WithContinuousListening — the
standalone server->client SSE stream, the only GET vMCP makes — solely on
the forwarding tools/call path.

Nothing pinned that, so add the regression the issue describes: a real
go-sdk stateful streamable-HTTP backend behind a handler that answers every
GET with Tableau's 400, asserting that ListCapabilities both succeeds and
issues no GET at all.

Verified to have teeth: appending WithContinuousListening unconditionally in
newStreamableHTTPClient makes the test fail on the GET assertion.
A client POSTing its answer to a server-initiated request — a ping result
today, elicitation and sampling results as those land — got
400 "Invalid or malformed MCP request" whenever authorization was enabled.
Clients treat that as a fatal transport error: VS Code tears the session
down and retries with a new session id, so the connection flaps once per
ping interval.

parseMCPRequest handles only JSON-RPC requests and returns nil for a
response, which the authz middleware could not tell apart from an
unparseable body, so it took the malformed-body branch.

Record in the parsing middleware that a body decoded as a JSON-RPC response
or error, and let the authz middleware pass those through. A response names
no method and reaches no tool, so there is nothing to authorize; the
streamable-HTTP transport already answers 202 for it, as the spec requires.

The content-type refusal ahead of this is untouched: a JSON-RPC body
smuggled under text/plain is still rejected before parsing, and a genuinely
malformed JSON body still gets the 400.

Fixes stacklok#5009
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.38%. Comparing base (6b40bf3) to head (327f0d3).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6521      +/-   ##
==========================================
- Coverage   78.43%   78.38%   -0.06%     
==========================================
  Files         776      776              
  Lines       76094    76108      +14     
==========================================
- Hits        59685    59656      -29     
- Misses      16404    16447      +43     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

vMCP streamable-http returns 400 on valid client responses to server-initiated requests

1 participant