Skip to content

Commit 9c9fd2a

Browse files
committed
fix(client): map HTTP 401 on streamable HTTP to Unauthorized JSON-RPC error
Operation-specific auth denials now surface as a distinguishable INVALID_REQUEST / "Unauthorized" JSON-RPC error with data.http_status instead of collapsing into the generic "Server returned an error response" fallback. Fixes #1295
1 parent 6705402 commit 9c9fd2a

3 files changed

Lines changed: 53 additions & 0 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,15 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
366366
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
367367
else:
368368
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
369+
elif response.status_code == 401:
370+
# Operation-specific auth denials must stay distinguishable so
371+
# agents can handle them (issue #1295) instead of collapsing into
372+
# an opaque "Server returned an error response".
373+
error_data = ErrorData(
374+
code=INVALID_REQUEST,
375+
message="Unauthorized",
376+
data={"http_status": 401},
377+
)
369378
else:
370379
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
371380
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))

tests/client/test_notification_response.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ async def test_http_error_status_sends_jsonrpc_error() -> None:
151151
await session.list_tools()
152152

153153

154+
async def test_http_401_surfaces_unauthorized_to_session() -> None:
155+
"""Bare HTTP 401 after initialize must surface as Unauthorized (issue #1295).
156+
157+
Agents need a distinguishable auth denial for operation-specific 401s, not the
158+
generic transport fallback string used for other 4xx/5xx statuses.
159+
"""
160+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=_create_http_error_app(401))) as client:
161+
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
162+
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
163+
await session.initialize()
164+
165+
with pytest.raises(MCPError, match="Unauthorized") as exc: # pragma: no branch
166+
await session.list_tools()
167+
assert exc.value.error.code == types.INVALID_REQUEST
168+
assert exc.value.error.data == {"http_status": 401}
169+
170+
154171
async def test_http_error_on_notification_does_not_hang() -> None:
155172
"""Verify HTTP errors on notifications are silently ignored.
156173

tests/client/test_streamable_http.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,33 @@ def handler(request: httpx2.Request) -> httpx2.Response:
132132
assert reply.message.error.code == METHOD_NOT_FOUND
133133

134134

135+
@pytest.mark.anyio
136+
async def test_bare_401_maps_to_unauthorized_jsonrpc_error() -> None:
137+
"""Bare HTTP 401 must reach the caller as a correlated, distinguishable JSON-RPC error.
138+
139+
Authorization failures can be operation-specific (issue #1295). Collapsing them into the
140+
generic "Server returned an error response" fallback prevents agents from handling the
141+
denial without tearing down the whole session.
142+
"""
143+
144+
def handler(request: httpx2.Request) -> httpx2.Response:
145+
return httpx2.Response(401)
146+
147+
with anyio.fail_after(5):
148+
async with (
149+
httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http,
150+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
151+
):
152+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={})))
153+
reply = await read.receive()
154+
assert isinstance(reply, SessionMessage)
155+
assert isinstance(reply.message, JSONRPCError)
156+
assert reply.message.id == 1
157+
assert reply.message.error.code == INVALID_REQUEST
158+
assert reply.message.error.message == "Unauthorized"
159+
assert reply.message.error.data == {"http_status": 401}
160+
161+
135162
@pytest.mark.anyio
136163
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
137164
"""``initialize`` discards the cached protocol-version header; every other POST reads it.

0 commit comments

Comments
 (0)