Skip to content

Redact the Authorization header from the errors the HTTP transport retains - #591

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:redact_the_bearer_token_from_retained_transport_errors
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:redact_the_bearer_token_from_retained_transport_errors

Conversation

@koic

@koic koic commented Oct 7, 2026

Copy link
Copy Markdown
Member

Motivation and Context

When the MCP server answers with an HTTP error, MCP::Client::HTTP raises RequestHandlerError with Faraday's exception as original_error, which is also its cause. Faraday's raise_error middleware keeps the request headers on that exception, bearer token included, and Faraday::Error#inspect prints them, so the token reached any log line or error report that inspected the error, while the transport already keeps credentials out of the URLs it quotes. The same exception escapes close unwrapped when the server refuses the DELETE.

The transport now replaces the value of the Authorization header in the retained request headers before such an exception leaves it, on requests, notifications, and session termination alike. The request is complete by then, and each request builds its own headers from the connection's defaults, so neither those defaults nor a later request observes the change; the response headers, which carry the WWW-Authenticate challenge the OAuth flow reads, are untouched.
The GET that resumes a stream the server closed early, and the POST that answers a request the server sent on such a stream, run while the caller's request is still in flight and can fail the same way, so they are covered too. A JSON middleware installed through the connection block raises Faraday::ParsingError on a malformed body, with the Faraday::Response itself in place of the Hash raise_error builds, so the header is replaced there as well. A middleware installed the same way may have replaced the request headers with a plain Hash or frozen them: the header name is matched regardless of case, and a frozen object is swapped for a copy, so the redaction never raises in place of the error it is redacting. Only the Authorization header is replaced; other request headers, Mcp-Session-Id among them, stay as they were sent.

How Has This Been Tested?

New tests in test/mcp/client/http_test.rb send a request, a notification, and a DELETE with a bearer token to a server that answers with an error, and check that the retained error carries [redacted] in place of the header and that inspect no longer contains the token. All three fail against the previous library. Two more resume a stream the server closed early with a GET that fails, and answer a request the server sent on that stream with a POST that fails, and check the same; both fail against the previous library. One more answers a server request carried by an SSE body an adapter without streaming support hands over whole, with a POST that fails, and checks the same; it fails against the previous library, and also when only the answer's own redaction is removed.
Three more install a middleware that parses JSON bodies and answer with a malformed one, freeze the request headers, or replace them with a plain Hash holding a lowercase authorization key, and check the same; all three fail against the previous library.
One more, in test/mcp/client/oauth/http_oauth_test.rb, answers a bearer request with a 401 challenge and checks that the OAuth flow still runs and the retried request carries the token it produced. The challenge names a metadata URL off the well-known paths, which stay unstubbed, so a challenge lost with the redaction would fail the flow.

Breaking Changes

None. original_error.response[:request][:headers]["Authorization"] now reads [redacted].

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

…tains

## Motivation and Context

When the MCP server answers with an HTTP error, `MCP::Client::HTTP` raises `RequestHandlerError` with
Faraday's exception as `original_error`, which is also its `cause`. Faraday's `raise_error` middleware
keeps the request headers on that exception, bearer token included, and `Faraday::Error#inspect` prints them,
so the token reached any log line or error report that inspected the error, while the transport already keeps
credentials out of the URLs it quotes. The same exception escapes `close` unwrapped when the server refuses the `DELETE`.

The transport now replaces the value of the `Authorization` header in the retained request headers before
such an exception leaves it, on requests, notifications, and session termination alike. The request is complete by then,
and each request builds its own headers from the connection's defaults, so neither those defaults nor
a later request observes the change; the response headers, which carry the `WWW-Authenticate` challenge
the OAuth flow reads, are untouched.
The `GET` that resumes a stream the server closed early, and the `POST` that answers a request the server sent on such a stream,
run while the caller's request is still in flight and can fail the same way, so they are covered too.
A JSON middleware installed through the connection block raises `Faraday::ParsingError` on a malformed body,
with the `Faraday::Response` itself in place of the Hash `raise_error` builds, so the header is replaced there as well.
A middleware installed the same way may have replaced the request headers with a plain Hash or frozen them:
the header name is matched regardless of case, and a frozen object is swapped for a copy, so the redaction never raises
in place of the error it is redacting. Only the `Authorization` header is replaced; other request headers,
`Mcp-Session-Id` among them, stay as they were sent.

## How Has This Been Tested?

New tests in `test/mcp/client/http_test.rb` send a request, a notification, and a `DELETE` with a bearer token to
a server that answers with an error, and check that the retained error carries `[redacted]` in place of
the header and that `inspect` no longer contains the token. All three fail against the previous library.
Two more resume a stream the server closed early with a `GET` that fails, and answer a request the server sent on
that stream with a `POST` that fails, and check the same; both fail against the previous library.
One more answers a server request carried by an SSE body an adapter without streaming support hands over whole,
with a `POST` that fails, and checks the same; it fails against the previous library, and also when only
the answer's own redaction is removed.
Three more install a middleware that parses JSON bodies and answer with a malformed one, freeze the request headers,
or replace them with a plain Hash holding a lowercase `authorization` key, and check the same; all three fail against
the previous library.
One more, in `test/mcp/client/oauth/http_oauth_test.rb`, answers a bearer request with a 401 challenge and checks
that the OAuth flow still runs and the retried request carries the token it produced.
The challenge names a metadata URL off the well-known paths, which stay unstubbed, so a challenge lost with
the redaction would fail the flow.

## Breaking Changes

None. `original_error.response[:request][:headers]["Authorization"]` now reads `[redacted]`.

This branch has not been deployed

No deployments
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