Skip to content

Commit 80defdd

Browse files
fix(client): map bare HTTP 401/403 to distinguishable JSON-RPC errors
Bare auth failures were collapsing into the generic "Server returned an error response" fallback, so agents could not handle operation-specific denials without tearing down the session. Surface Unauthorized/Forbidden with http_status metadata instead. Fixes #1295 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a4f4ccd commit 80defdd

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,21 @@ 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=INTERNAL_ERROR,
375+
message="Unauthorized",
376+
data={"http_status": 401},
377+
)
378+
elif response.status_code == 403:
379+
error_data = ErrorData(
380+
code=INTERNAL_ERROR,
381+
message="Forbidden",
382+
data={"http_status": 403},
383+
)
369384
else:
370385
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
371386
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.INTERNAL_ERROR
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
CLIENT_CAPABILITIES_META_KEY,
2020
CLIENT_INFO_META_KEY,
2121
CONNECTION_CLOSED,
22+
INTERNAL_ERROR,
2223
INVALID_REQUEST,
2324
METHOD_NOT_FOUND,
2425
PROTOCOL_VERSION_META_KEY,
@@ -132,6 +133,40 @@ def handler(request: httpx2.Request) -> httpx2.Response:
132133
assert reply.message.error.code == METHOD_NOT_FOUND
133134

134135

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

0 commit comments

Comments
 (0)