Skip to content

Commit a6b5613

Browse files
committed
fix(client): surface streamable HTTP 401 as JSON-RPC error
1 parent 220d362 commit a6b5613

2 files changed

Lines changed: 36 additions & 1 deletion

File tree

src/mcp/client/streamable_http.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
296296
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
297297
else:
298298
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
299+
elif response.status_code == 401:
300+
error_data = ErrorData(code=INTERNAL_ERROR, message="Unauthorized")
299301
else:
300302
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
301303
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))

tests/client/test_streamable_http.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
import httpx
1414
import pytest
1515
from inline_snapshot import snapshot
16-
from mcp_types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
16+
from mcp_types import (
17+
INTERNAL_ERROR,
18+
METHOD_NOT_FOUND,
19+
JSONRPCError,
20+
JSONRPCNotification,
21+
JSONRPCRequest,
22+
JSONRPCResponse,
23+
)
1724

1825
from mcp.client.streamable_http import streamable_http_client
1926
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, encode_header_value
@@ -103,6 +110,32 @@ def handler(request: httpx.Request) -> httpx.Response:
103110
assert reply.message.error.code == METHOD_NOT_FOUND
104111

105112

113+
@pytest.mark.anyio
114+
async def test_bare_401_request_maps_to_unauthorized_jsonrpc_error() -> None:
115+
"""A bare HTTP 401 should reach the caller as a correlated JSON-RPC error.
116+
117+
Authorization failures can be operation-specific. The client transport must
118+
leave room for the agent/session layer to handle the denial instead of
119+
collapsing it into an indistinguishable transport failure.
120+
"""
121+
122+
def handler(request: httpx.Request) -> httpx.Response:
123+
return httpx.Response(401)
124+
125+
with anyio.fail_after(5):
126+
async with (
127+
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
128+
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
129+
):
130+
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={})))
131+
reply = await read.receive()
132+
assert isinstance(reply, SessionMessage)
133+
assert isinstance(reply.message, JSONRPCError)
134+
assert reply.message.id == 1
135+
assert reply.message.error.code == INTERNAL_ERROR
136+
assert reply.message.error.message == "Unauthorized"
137+
138+
106139
@pytest.mark.anyio
107140
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
108141
"""``initialize`` discards the cached protocol-version header; every other POST reads it.

0 commit comments

Comments
 (0)