Skip to content

feat: MCP 2025-11-25 spec compliance + real-world OAuth#2

Open
bowlofarugula wants to merge 9 commits intomasterfrom
feat/mcp-2025-11-25-spec-compliance
Open

feat: MCP 2025-11-25 spec compliance + real-world OAuth#2
bowlofarugula wants to merge 9 commits intomasterfrom
feat/mcp-2025-11-25-spec-compliance

Conversation

@bowlofarugula
Copy link
Copy Markdown

@bowlofarugula bowlofarugula commented Apr 10, 2026

Summary

Brings murl's OAuth and transport handling into compliance with the MCP 2025-11-25 specification, and fixes real-world issues discovered testing against a live Okta-backed Bedrock AgentCore server.

Authorization (auth.py)

  • Protected Resource Metadata discovery (RFC 9728) — resource_metadata from WWW-Authenticate header, path-aware and root well-known URI fallback
  • Authorization Server Metadata (RFC 8414) with OIDC Discovery fallback — correct priority order for path-aware and root endpoints
  • PKCE S256 enforcement — refuses if code_challenge_methods_supported is absent (with tolerance for OIDC endpoints that omit it, e.g. Okta)
  • Resource Indicators (RFC 8707) — resource parameter in authorization, token exchange, and refresh requests
  • Pre-configured OAuth credentials--client-id, --client-secret, --callback-port, --scope flags (Claude Code pattern, skips DCR)
  • Scope selection strategy — WWW-Authenticate > Protected Resource Metadata scopes_supported > omit
  • Canonical resource URI — no trailing slash for bare hostnames per spec SHOULD
  • localhost redirect URI — IDPs register localhost, not 127.0.0.1
  • 401 probe for --login path — ensures proper RFC 9728 discovery on servers with complex URLs

Transport & Lifecycle (cli.py)

  • Capability negotiation — checks server capabilities before dispatching methods (MUST per lifecycle spec)
  • 403 step-up authorization — parses insufficient_scope from WWW-Authenticate, re-authorizes with required scopes
  • Cursor-based paginationtools/list, resources/list, prompts/list follow nextCursor to collect all pages

Testing

  • 118 tests (up from 106), all passing
  • New test coverage: WWW-Authenticate parsing, RFC 9728 discovery, RFC 8414 + OIDC discovery, canonical URI, PKCE enforcement (including OIDC tolerance), pre-configured credentials, resource parameter in refresh, pagination (tools/resources/prompts + single-page)
  • Verified end-to-end against live Bedrock AgentCore + Okta OAuth

Spec references

Code comments throughout cite specific MCP 2025-11-25 sections and RFCs for traceability.

Test plan

  • All 118 unit tests pass (pytest tests/test_auth.py tests/test_cli.py)
  • Integration tested against live Bedrock AgentCore MCP server with Okta OAuth
  • Pre-configured credentials (--client-id, --client-secret, --callback-port, --scope) verified against Okta IDP
  • Stored credentials auto-attach on subsequent calls
  • 401 retry and 403 step-up paths exercised
  • Test against additional MCP servers with different OAuth configurations

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added OAuth configuration CLI options: --client-id, --client-secret, --callback-port, and --scope
    • Implemented cursor-based pagination for list operations to retrieve complete result sets
    • Enhanced authentication with automatic resource discovery and error recovery
    • Improved resource URI handling and metadata negotiation
  • Improvements

    • Better 401/403 error handling with automatic re-authorization flow
    • Enhanced MCP capability negotiation validation

bowlofarugula and others added 7 commits April 10, 2026 15:56
Bring murl into compliance with the MCP 2025-11-25 specification for
Streamable HTTP transport, authorization, and lifecycle.

Authorization (auth.py):
- Add Protected Resource Metadata discovery (RFC 9728) with well-known
  URI fallback and WWW-Authenticate header extraction
- Add Authorization Server Metadata discovery with OIDC Discovery
  fallback (RFC 8414 + OpenID Connect)
- Include resource parameter in auth and token requests (RFC 8707)
- Parse WWW-Authenticate headers for resource_metadata, scope, and
  error parameters
- Verify PKCE S256 support before proceeding with authorization
- Add scope selection strategy (WWW-Authenticate > resource metadata)
- Maintain backwards compatibility via legacy discovery fallback

Transport/Lifecycle (cli.py):
- Check server capabilities after initialization before dispatching
  requests (lifecycle MUST requirement)
- Extract WWW-Authenticate headers from actual HTTP 401/403 responses
  instead of string-matching exception messages
- Handle 403 insufficient_scope with step-up re-authorization
- Support arbitrary resource URI schemes (not just file://) with
  -d uri=... override

Tests: 106 passing (up from ~80), covering all new discovery functions,
WWW-Authenticate parsing, capability checking, 403 step-up flow, and
resource URI handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement OAuth Client ID Metadata Documents per
draft-ietf-oauth-client-id-metadata-document-00, the MCP spec's
preferred client registration mechanism.

When an authorization server advertises
client_id_metadata_document_supported: true in its metadata, murl now
skips Dynamic Client Registration and instead uses a hosted metadata
document URL as its client_id. This avoids the need for a registration
endpoint and follows the spec's recommended priority order.

Changes:
- Add docs/oauth/client-metadata.json for hosting on GitHub Pages
- Add CIMD-aware registration in authorize(): checks for
  client_id_metadata_document_supported, uses fixed callback port
  (19362) to match pre-registered redirect URIs in the metadata doc
- Fall back to Dynamic Client Registration when CIMD is not supported
- Improve error message when neither CIMD nor DCR is available

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gured OAuth

Support pre-registered OAuth credentials as an alternative to Dynamic Client
Registration, matching the pattern used by Claude Code. When --client-id is
provided, DCR is skipped entirely. Without --client-id and no registration
endpoint advertised, a clear error directs users to provide credentials.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two spec compliance fixes found during review:

1. refresh_token() now includes the RFC 8707 `resource` parameter in
   token refresh requests. MCP 2025-11-25 requires it in all token
   requests to maintain audience binding. Gracefully omitted for legacy
   creds that predate the resource_uri field.

2. _canonical_resource_uri() no longer adds a synthetic trailing slash
   for bare hostnames. The spec says implementations SHOULD use the form
   without trailing slash unless semantically significant.

Also adds spec-reference comments (MCP 2025-11-25 section citations)
throughout auth.py and cli.py for discovery, PKCE, scope selection,
client registration priority, and resource parameter handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tools/list, resources/list, and prompts/list now follow nextCursor to
collect all pages instead of silently returning only the first page.
Servers with many tools/resources/prompts will now return complete
results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three issues discovered testing against a live Okta-backed AgentCore server:

1. OIDC PKCE tolerance: Okta's OIDC discovery metadata omits
   code_challenge_methods_supported (it's an OAuth 2.0 AS Metadata field,
   not OIDC). We now warn and proceed with S256 when metadata comes from
   an OIDC endpoint, rather than refusing outright. RFC 8414 sources
   still enforce the strict check.

2. Redirect URI uses localhost instead of 127.0.0.1: IDPs do exact string
   matching on redirect_uri. Most register http://localhost:<port>/callback,
   not the IP form.

3. Added --scope flag: Okta requires an explicit scope when the auth server
   has no configured defaults. Also added a 401 probe (_probe_www_authenticate)
   so --login and expired-token paths get the WWW-Authenticate header for
   proper RFC 9728 discovery on servers with complex URLs.

Verified end-to-end against live Bedrock AgentCore + Okta OAuth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9581c9c2-91c0-47c8-9bfa-4ba70760441c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The changes update the OAuth authentication flow and CLI to support MCP 2025-11-25 compliance. New discovery functions were added to handle RFC 9728 resource metadata and RFC 8414/OIDC metadata endpoints. The authorize() function signature expanded to accept OAuth parameters (client_id, client_secret, scope, callback_port, www_authenticate), with logic updated to compute resource URIs and support preconfigured credentials. The CLI now handles cursor-based pagination for list operations, validates server capabilities, and implements 401/403 retry logic with dynamic OAuth re-authorization. Comprehensive test coverage was added for new discovery functions, resource URI canonicalization, and pagination behavior.


Comment @coderabbitai help to get the list of available commands and usage tips.

The MCP SDK logs a warning when the server returns 404 on session DELETE,
which is valid per MCP 2025-11-25 — 404 means the session is already gone.
Suppress by setting the SDK transport logger to ERROR level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@murl/auth.py`:
- Around line 493-496: The redirect_uri uses "localhost" but
_run_callback_server() binds to "127.0.0.1", causing mismatched host/address
families; update _run_callback_server() to bind to the same advertised host (use
the same host variable used to build redirect_uri, e.g., "localhost") or make it
listen on both IPv4 and IPv6 loopback (both 127.0.0.1 and ::1) so the browser
callback reaches the server; ensure you reference and reuse the same host/port
values when creating the HTTPServer/listen socket in _run_callback_server() to
keep redirect_uri and the bound listener consistent.
- Around line 427-447: The code currently wraps both resource metadata discovery
and auth-server discovery in one try/except, causing failures in
discover_auth_server_metadata(issuer_url) to fall back to
discover_metadata(server_url) incorrectly; instead split the logic: call
discover_resource_metadata(server_url, resource_metadata_url) inside a try that
on OAuthError sets auth_server_meta = discover_metadata(server_url) (this
handles "resource metadata unavailable"), but if resource discovery succeeds
then extract issuer_url and call discover_auth_server_metadata(issuer_url)
outside that except (so any OAuthError from discover_auth_server_metadata
propagates and does not silently fallback); update references to
auth_server_meta and issuer_url accordingly (functions:
discover_resource_metadata, discover_auth_server_metadata, discover_metadata;
variables: resource_meta, issuer_url, auth_server_meta).

In `@murl/cli.py`:
- Around line 268-308: The pagination loops for session.list_tools,
session.list_resources and session.list_prompts trust result.nextCursor and can
hang on repeated tokens; modify each loop to track seen cursors (e.g., seen =
set()) and a sane max page count (e.g., MAX_PAGES = 1000), and before assigning
cursor = result.nextCursor check if nextCursor is None or nextCursor in seen or
pages >= MAX_PAGES and break if so; add seen.add(nextCursor) and increment pages
each iteration so all_items accumulation and the return lines (the model_dump
calls) remain unchanged.
- Around line 587-597: Only trigger re-authorization on a 403 when the
WWW-Authenticate header explicitly indicates "insufficient_scope" and avoid
passing scope twice by merging any parsed scope into auth_kwargs before calling
authorize; specifically, when is_403 and www_auth_header exists, call
parse_www_authenticate(www_auth_header), check that its error or params indicate
insufficient_scope, set scope_to_request = www_params.get("scope") if present,
then if scope_to_request is set update auth_kwargs["scope"] = scope_to_request
(overriding any CLI-provided scope) and finally call authorize(base_url,
www_authenticate=www_auth_header, **auth_kwargs) instead of passing scope as a
separate positional/keyword argument.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 82a0c276-6a55-4aa7-85e8-e8bd8607c6a4

📥 Commits

Reviewing files that changed from the base of the PR and between 42f9f80 and 5c738e5.

📒 Files selected for processing (4)
  • murl/auth.py
  • murl/cli.py
  • tests/test_auth.py
  • tests/test_cli.py

- Split discovery try/except so auth server metadata failures propagate
  when the issuer was explicitly advertised via RFC 9728 (only fall back
  to legacy discovery when resource metadata itself is unavailable)
- Bind callback server to "localhost" instead of "127.0.0.1" to match
  the redirect_uri host (fixes IPv6-first machines where ::1 != 127.0.0.1)
- Add pagination loop guards: track seen cursors + max page limit (1000)
  to prevent infinite loops from buggy servers
- Fix 403 handler: only re-auth on explicit insufficient_scope error,
  and merge scope into auth_kwargs to avoid duplicate kwarg TypeError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bowlofarugula
Copy link
Copy Markdown
Author

Addressed all four CodeRabbit findings in 69b419d:

  1. auth.py — Split discovery try/except — Only resource metadata unavailability now falls back to legacy discovery. If the issuer is explicitly advertised via RFC 9728 but its auth server metadata fetch fails, that error propagates instead of silently falling back.

  2. auth.py — localhost callback binding_run_callback_server() now binds to "localhost" instead of "127.0.0.1", matching the redirect_uri host. Fixes IPv6-first machines where localhost resolves to ::1.

  3. cli.py — Pagination loop guards — All three pagination loops now track seen cursors in a set() and enforce MAX_PAGES=1000 to prevent infinite loops from buggy servers that repeat or cycle cursors.

  4. cli.py — 403 scope handling — Now only re-authorizes on explicit insufficient_scope error (not all 403s). Merges the parsed scope into auth_kwargs via reauth_kwargs = {**auth_kwargs, "scope": ...} to avoid the duplicate-kwarg TypeError.

Copy link
Copy Markdown

@turlockmike turlockmike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three-lens review: security, Unix design, test quality.

Overall: Strong work. The PKCE implementation is correct, spec traceability is excellent, and the discovery chain composition is clean. Several items worth addressing before merge.

Cross-reviewer themes:

  1. Discovery chain URL validationresource_metadata URLs from WWW-Authenticate headers and authorization_servers entries are followed without origin validation. A malicious MCP endpoint can chain resource_metadataauthorization_servers to redirect the entire OAuth flow through an attacker-controlled server. RFC 9728 §3 requires the metadata URL to be under the same origin — validate hostname matches before following.

  2. _CallbackHandler class-level state — All three reviewers flagged this independently. auth_code, auth_error, and expected_state are class attributes, not instance attributes. If authorize() is called concurrently (or twice in the same process on 401 retry), both flows write to the same class slots. The reset at lines 331-333 patches this but is load-bearing. Use closures or server.data dict. Test suite also never exercises _CallbackHandler directly — state mismatch, error parameter, and no-code paths are untested.

  3. Pagination duplication — The cursor-based pagination loop (all_items, cursor, seen_cursors, for _ in range(MAX_PAGES)) appears three times verbatim for tools/resources/prompts. Extract a _collect_all(fetch_page, extract_items) helper. When you fix the cursor cycle guard, fix it in one place.

  4. --client-secret in process args — Visible in ps aux, /proc/<pid>/cmdline, and shell history. Support MURL_CLIENT_SECRET env var or file-based input as an alternative.

Additional observations:

  • _probe_www_authenticate fires unconditionally on --login — gate it behind discovery failure to avoid unnecessary round-trips
  • discover_metadata is kept for backwards compat but duplicates discover_auth_server_metadata's fallback chain with one difference: silent default endpoint synthesis. Name that behavior explicitly or consolidate.
  • Token exchange error messages include raw server response body — can leak internal issuer details to stderr/logs
  • Test assertions on URL call ordering (test_success) and _discovery_source internal tag couple to implementation details rather than observable behavior
  • Missing test for discover_resource_metadata network error path (except httpx.HTTPError: continue)
  • Pagination tests share 20+ lines of identical async scaffolding — extract to a pytest fixture
  • test_cli_401_retry_triggers_oauth imports the real make_mcp_request inside a mock side effect — return a concrete result instead to avoid recursive risk
  • Deferred import click and import socket inside authorize() save nothing — both already loaded; move to top of file
  • Bare raise inside 403 handler at cli.py:605 re-raises correctly but the intent is unclear — add a comment like # Not a scope challenge — propagate as network error

What's good: PKCE S256 done correctly (secrets.token_urlsafe(64), proper SHA-256 + no-padding base64url), PKCE enforcement distinguishes RFC 8414 vs OIDC metadata (the Okta tolerance is a good call), 403 handling correctly gates on insufficient_scope, resource indicators in both auth and token exchange, capability negotiation in the right place, spec comment traceability throughout. The _discovery_source tag carrying provenance through the data is a nice touch.

— Mike's AI Clone 🧬

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.

2 participants