Commit graph ruby-sdk/lib/mcp/client/http.rb
Author SHA1 Message Date
Koichi ITO
a407cea086 Re-run OAuth flow on 403 insufficient_scope (step-up)
## Motivation and Context

The MCP authorization specification (2025-11-25 scope-selection-strategy)
and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication:
the client already holds a valid access token, but the server returns a 403
with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
to demand a higher-privileged scope for a specific operation.
The client is expected to run a fresh authorization request with
the escalated scope and retry.

Until now the Ruby SDK only ran the OAuth flow on 401 responses.
A 403 surfaced unchanged as a `RequestHandlerError` and the operation
that needed the escalated scope failed permanently, even with a valid token
in storage. This lined up with the `auth/scope-step-up` conformance
scenario being listed in `conformance/expected_failures.yml`.

The change adds:

- `MCP::Client::HTTP#send_request` recognises a 403 carrying
  an `insufficient_scope` Bearer challenge as a step-up signal,
  re-runs a full authorization request with the scope from the challenge,
  and retries the original request. Retries are capped at
  one per `send_request` call so a server that repeatedly demands more
  scope cannot loop the transport.
- `run_step_up_flow!` deliberately bypasses the refresh-token path that
  `run_oauth_flow!` uses on 401. Refreshing would re-issue the same
  scope set the existing token already has and the server already
  rejected. Only a fresh `authorization_code` grant with the escalated
  scope can succeed.
- A plain 403 with no `insufficient_scope` challenge continues to
  surface as `RequestHandlerError` with `error_type: :forbidden`.
- `conformance/client.rb` invokes a tool for the `auth/scope-step-up`
  scenario; the scenario's authorization middleware only requires
  the escalated scope on `tools/call`, so the previous `tools/list`-only
  client could never trigger the second authorization.
- `auth/scope-step-up` is removed from `conformance/expected_failures.yml`.

Resolves #383.

## How Has This Been Tested?

New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path
re-authorizes with the escalated scope from the challenge and retries
the original request with the new bearer token; a plain 403 without
the `insufficient_scope` challenge is surfaced as
`error_type: :forbidden` with no OAuth side effects; repeated
`insufficient_scope` responses retry at most once and then surface the 403;
a provider with a saved `refresh_token` still goes through
the full authorization request rather than exchanging the refresh token,
because the existing scope set is precisely what was rejected.

Conformance: `auth/scope-step-up` now passes 22/22 with no warnings.
`bundle exec rake test`, `bundle exec rake rubocop`, and
`bundle exec rake conformance` are all green.

## Breaking Changes

None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope`
Bearer challenge still raises the same `RequestHandlerError` as before.
The new retry only fires when both the WWW-Authenticate header signals step-up
and the transport was constructed with an OAuth provider.
2026-06-12 11:59:02 +09:00
Koichi ITO
5766069923 Add OAuth 2.1 client support for MCP authorization flow
## Motivation and Context

The MCP Authorization spec (2025-11-25) requires clients to support
Protected Resource Metadata discovery (RFC 9728), Authorization
Server Metadata discovery (RFC 8414), Dynamic Client Registration,
and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK
previously had no client-side OAuth support: the HTTP transport raised
`RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios
were listed as known failures in `conformance/expected_failures.yml`.

This aligns the Ruby SDK with the Python and TypeScript SDKs,
both of which expose a pluggable OAuth client provider that drives
the full discovery + DCR + authorization + token exchange flow.
`MCP::Client::OAuth::Provider` mirrors that surface: callers supply
`client_metadata`, a `redirect_uri`, and pluggable `redirect_handler`
/ `callback_handler` hooks plus a token/client information storage.
`MCP::Client::OAuth::InMemoryStorage` is provided as a default.

`MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present,
the transport attaches `Authorization: Bearer` to every request,
and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL
and `scope` challenge, drives the discovery + DCR + authorization code flow
through the provider, and retries the failed request once with the acquired token.

Spec compliance details:

- The `WWW-Authenticate` parser handles multiple challenges
  (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only
  the parameters that belong to the Bearer challenge.
- The PRM `resource` value is required to cover the MCP server URL
  (same scheme/host/port, with the PRM path as a prefix of the server path)
  before the flow continues, preventing redirection of issued tokens to a different audience.
- Both authorization and token requests always include a `resource`
  parameter (RFC 8707), defaulting to a canonicalized form of
  the MCP server URL when PRM does not advertise one.
- `client_secret_basic`, `client_secret_post`, and `none` token endpoint
  authentication methods are supported. When pre-registered client information
  specifies no `token_endpoint_auth_method`, the transport defaults to
  `client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention.
  Pre-registered client credentials supplied via `Storage#save_client_information`
  with either string or symbol keys are honored, skipping DCR.
- Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first,
  then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only
  if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope).
- Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with
  `error: "invalid_grant"` raises `Flow::InvalidGrantError`,
  so `MCP::Client::HTTP` can discard the stored refresh token only when
  the AS truly says it is dead. Generic `AuthorizationError`
  (5xx, network failures, other error codes) leaves the refresh token
  intact, so a transient AS outage does not force interactive reauth
  on the next attempt.
- The OAuth `state` parameter is compared via
  `OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to
  prevent timing-based discovery of the expected value.
- The 401 retry guard is a request-scoped local variable, so an MCP server
  that keeps returning 401 is surfaced as an error after one
  retry rather than triggering an infinite re-authorization loop.

## How Has This Been Tested?

Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing,
PRM and Authorization Server metadata URL builders, the full Flow orchestrator
(success path, state mismatch, missing PKCE support, skipping DCR for both string
and symbol-keyed preregistered client information, `client_secret_basic`
token endpoint authentication, PRM `resource` mismatch rejection, fallback to
the server URL when PRM omits `resource`, scope resolution preferring PRM
`scopes_supported` over `Provider#scope`, default `client_secret_basic` for
confidential clients without an explicit `token_endpoint_auth_method`,
and the new `InvalidGrantError` vs generic `AuthorizationError` split for
token endpoint rejections), and the HTTP transport behavior
(401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401,
no infinite loop on flow failure, refresh-token retention on transient 5xx from
the token endpoint).

Conformance: `bundle exec rake conformance` newly passes 16 `auth/*`
scenarios beyond the existing baseline, including
`auth/metadata-default`, `metadata-var{1,2,3}`,
`scope-from-www-authenticate`, `scope-from-scopes-supported`,
`scope-omitted-when-undefined`, `scope-retry-limit`,
`token-endpoint-auth-{basic,post,none}`, `pre-registration`,
`resource-mismatch`, and `offline-access-not-supported`. Remaining
`auth/*` scenarios stay in `expected_failures.yml`.
`bundle exec rake test rubocop` is clean.

## Breaking Changes

None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:`
keyword that defaults to `nil`, preserving existing behavior. When unset,
the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before.
2026-05-17 00:48:41 +09:00
Ates Goral
ddfbfad81e Add client-level connect for initialize handshake 2026-05-01 20:17:45 -04:00
Ates Goral
ff3a42f3fb Add HTTP client close for explicit session termination
Per MCP spec, clients that no longer need a session SHOULD send an HTTP
DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
terminate it. Servers MAY respond with 405 Method Not Allowed when they
don't support client-initiated termination.

`MCP::Client::HTTP#close` sends the DELETE with session headers, clears
local session state regardless of outcome, and is a no-op when no
session has been established.

https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
2026-04-20 13:58:41 -04:00
Ates Goral
07226b7cf1 Track Mcp-Session-Id and protocol version in HTTP client
Per the Streamable HTTP spec, if the server returns an Mcp-Session-Id
header during `initialize`, the client MUST include it on subsequent
requests. Capture the session ID and protocol version from the
`initialize` response and attach them automatically on subsequent
POSTs.

Per the Protocol Version Header section of the spec, the client MUST
also include `MCP-Protocol-Version` on all subsequent requests so the
server can respond based on the negotiated protocol version.

Map 404 responses to a new `SessionExpiredError` (a subclass of
`RequestHandlerError` for backward compatibility) and clear local
session state so callers can start a fresh session with a new
`initialize` request, as required by the spec.

- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 00:23:07 -04:00
Ates Goral
cfc42dbfe7 Handle 202 Accepted response in HTTP client
Per the Streamable HTTP spec, a server returns 202 Accepted with no
body as the ACK for a JSON-RPC notification or response. The client
previously errored on 202 because it fell through Content-Type
dispatch. Short-circuit to return nil before Content-Type handling so
fire-and-forget messages (e.g. notifications/initialized) no longer
raise.

https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:06:07 -04:00
Ates Goral
b832933f9f Parse SSE responses in HTTP client via event_stream_parser
The Streamable HTTP spec allows servers to respond with either
`application/json` or `text/event-stream`. The client previously
rejected the latter. Add SSE parsing via the optional
`event_stream_parser` gem (users add it to their Gemfile when their
server uses SSE), dispatch on the response Content-Type, and scan the
event stream for the first JSON-RPC response message.

Document the new optional dependency in both the README's HTTP
Transport Layer section and docs/building-clients.md.

No other Streamable HTTP features (202 handling, session IDs,
`connect`, DELETE termination) are included here; those will land in
follow-up PRs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:54:03 -04:00
Koichi ITO
f7952e8aaf Support customizing the Faraday client in MCP::Client::HTTP
## Motivation and Context

`MCP::Client::HTTP` builds its Faraday connection internally in a private `client` method
with no way to customize the middleware stack or adapter.
Users who need observability (request/response recording, failure logging) or
a different HTTP adapter must override a private method, coupling to an internal API.

This accepts an optional block in `MCP::Client::HTTP.new` that yields the Faraday builder
after default middleware is configured, allowing users to add custom middleware or swap
the HTTP adapter.

## How Has This Been Tested?

Added a test that verifies custom headers set via the block are included in requests.
All existing tests continue to pass.

## Breaking Changes

None. The block argument is optional, so existing code is unaffected.

Resolves #303
2026-04-09 05:08:49 +09:00
Keisuke Umegaki
ab8f53c5b7
Add Accept header validation
- Set Accept header to request JSON or SSE responses
- Validate response Content-Type and raise error for non-JSON responses
- Add Accept header to test fixtures
- Use explicit hash syntax for Ruby 2.7 compatibility
2026-01-07 14:55:19 +09:00
Joey Cardosi
8b764428b1
Add basic HTTP client support with pluggable transports 2025-09-08 15:52:37 -05:00