Skip to content

Conversation

@sd2k
Copy link
Contributor

@sd2k sd2k commented Nov 19, 2025

Description

Add support for discovering OAuth authorization server metadata from WWW-Authenticate headers per RFC9728 Section 5.1.

The MCP spec indicates that servers should return a 401 Unauthorized response with a WWW-Authenticate header containing the resource_metadata parameter. This parameter is used to discover the OAuth authorization server metadata.

This change adds support for this discovery, allowing clients to automatically extract the OAuth metadata URL from the WWW-Authenticate header and use it to discover the OAuth authorization server configuration, rather than relying on it being on the /.well-known path of the base URL, which is not always the case (for example,
https://mcp.linear.app/mcp/.well-known/oauth-protected-resource vs https://mcp.honeycomb.io/.well-known/oauth-protected-resource - note the lack of /mcp in one of these, even though both servers expect the /mcp path in the base URL).

Changes:

  • Add AuthorizationRequiredError base error type with ResourceMetadataURL field
  • Add OAuthAuthorizationRequiredError that embeds AuthorizationRequiredError
  • Add ProtectedResourceMetadataURL to OAuthConfig for explicit configuration
  • Extract resource_metadata parameter from WWW-Authenticate headers in both streamable_http and sse transports
  • Update getServerMetadata() to use ProtectedResourceMetadataURL when provided
  • Add helper functions: IsAuthorizationRequiredError(), GetResourceMetadataURL()
  • Add comprehensive tests for metadata URL extraction and usage
  • Update OAuth example to demonstrate RFC9728 discovery

This allows clients to properly discover OAuth endpoints when servers return 401 responses with WWW-Authenticate headers containing resource_metadata URLs, enabling correct OAuth flows without requiring well-known URL assumptions.

RFC9728: https://datatracker.ietf.org/doc/html/rfc9728

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • MCP spec compatibility implementation
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring (no functional changes)
  • Performance improvement
  • Tests only (no functional changes)
  • Other (please describe):

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly

MCP Spec Compliance

  • This PR implements a feature defined in the MCP specification
  • Link to relevant spec section: Authorization server location
  • Implementation follows the specification exactly

Additional Information

I'm using this fork locally, will mark as ready-for-review once I'm sure it works correctly.

Summary by CodeRabbit

  • New Features

    • RFC9728 OAuth Protected Resource Metadata discovery support
    • Automatic extraction and propagation of OAuth metadata URLs from server WWW-Authenticate headers
    • New client helpers to detect authorization-required errors and retrieve discovered metadata
    • New config option to explicitly specify protected resource metadata URL
  • Tests

    • Added tests covering OAuth metadata discovery and related error paths
  • Documentation

    • Updated example README and sample to show automatic metadata discovery and usage

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 19, 2025

Walkthrough

Adds RFC9728 protected resource metadata discovery and propagation into client transports and tests; exposes a client-level alias and helpers for authorization-required errors; adds OAuthConfig ProtectedResourceMetadataURL and updates docs/examples to surface discovered metadata URL.

Changes

Cohort / File(s) Summary
Client error helpers
client/oauth.go
Added public alias AuthorizationRequiredError = transport.AuthorizationRequiredError, IsAuthorizationRequiredError(err error) bool, and GetResourceMetadataURL(err error) string.
Client tests
client/oauth_test.go
Added TestGetResourceMetadataURL, expanded TestIsAuthorizationRequiredError to cover base and OAuth-wrapped errors and metadata extraction.
OAuth config / discovery
client/transport/oauth.go
Added ProtectedResourceMetadataURL string to OAuthConfig; getServerMetadata now prefers the explicit config value over the constructed well-known protected-resource URL.
HTTP transport error plumbing
client/transport/streamable_http.go
Introduced AuthorizationRequiredError (with ResourceMetadataURL), extractResourceMetadataURL() helper, and updated 401 handling to attach resource metadata; extended OAuthAuthorizationRequiredError to embed the base error.
SSE transport metadata handling
client/transport/sse.go
Updated Start, SendRequest, SendNotification to extract resource_metadata from WWW-Authenticate on 401 and return either OAuthAuthorizationRequiredError (when OAuth configured) or AuthorizationRequiredError with the metadata URL.
Transport tests
client/transport/sse_oauth_test.go, client/transport/streamable_http_oauth_test.go
Added TestSSE_OAuthMetadataDiscovery, TestStreamableHTTP_OAuthMetadataDiscovery, and TestExtractResourceMetadataURL to validate RFC9728 metadata extraction and edge cases.
Examples & docs
examples/oauth_client/README.md, examples/oauth_client/main.go
Documented automatic RFC9728 metadata discovery, updated flow steps and example usage showing retrieval of discovered metadata via GetResourceMetadataURL(err).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Verify embedding of AuthorizationRequiredError into OAuthAuthorizationRequiredError preserves previous error unwrapping behavior.
  • Review extractResourceMetadataURL() parsing for quoted values, multiple parameters, empty/malformed headers, and DPoP variations.
  • Check consistent propagation of ResourceMetadataURL through SSE, SendRequest, SendNotification, and token-retrieval failure paths.

Possibly related PRs

Suggested labels

type: enhancement, area: mcp spec, status: needs submitter response

Suggested reviewers

  • ezynda3
  • giridhar-murthy-glean
  • pottekkat

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive The description includes most required template sections with comprehensive details about the RFC9728 implementation, files changed, and MCP spec compliance. However, the documentation update checklist item is unchecked despite README modifications. Check the 'Documentation update' box in the Type of Change section since examples/oauth_client/README.md was modified, or clarify if README changes should not count as documentation updates.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: implementing RFC9728 OAuth Protected Resource Metadata discovery.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@sd2k sd2k marked this pull request as ready for review November 20, 2025 10:34
Copy link
Contributor

@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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/oauth_client/README.md (1)

7-12: Fix helper name in README example to match exported API

The RFC9728 section shows:

if metadataURL := client.GetDiscoveredMetadataURL(err); metadataURL != "" {
    fmt.Printf("Server provided OAuth metadata URL: %s\n", metadataURL)
}

but the exported helper is client.GetResourceMetadataURL(err), and the main example file uses that name. Please rename the README snippet to GetResourceMetadataURL (or add a real alias) so documentation matches the actual API.

Also applies to: 29-41, 63-76

🧹 Nitpick comments (4)
client/transport/sse.go (2)

166-187: 401 handling and metadata propagation look correct; optionally clean up response channels on error

The new 401 branches correctly:

  • Extract resource_metadata from WWW-Authenticate via extractResourceMetadataURL.
  • Return OAuthAuthorizationRequiredError (when oauthHandler is present) embedding AuthorizationRequiredError with the discovered ResourceMetadataURL.
  • Fall back to AuthorizationRequiredError for non-OAuth SSE usage.

That matches the intended RFC9728 behavior and is consistent across Start, SendRequest, and SendNotification.

One optional improvement for SendRequest: in the non-success status paths (including this new 401 branch), responses[idKey] remains registered in c.responses, since deleteResponseChan() is not called before returning. This was already true for non-401 errors but now applies to 401 as well. If you expect many failed requests, you may want to invoke deleteResponseChan() before returning to avoid leaving stale entries in the map.

Also applies to: 443-461, 580-597


143-159: Replace string comparisons with errors.Is() for sentinel error checking

You correctly identified that SendNotification uses errors.Is(err, ErrOAuthAuthorizationRequired) at line 551, but both Start (line 148) and SendRequest (line 394) still use brittle string comparison:

if err.Error() == "no valid token available, authorization required" {

The sentinel error ErrOAuthAuthorizationRequired is defined in streamable_http.go:238 and is available in sse.go (which imports the errors package). Switching to errors.Is() makes the code robust to wrapped errors, consistent with the SendNotification pattern, and aligns with the coding guidelines mandate to use errors.Is/As with sentinel errors.

Update lines 148 and 394 to use:

if errors.Is(err, ErrOAuthAuthorizationRequired) {
client/oauth_test.go (1)

129-211: Migrate new tests to testify for consistency with codebase testing patterns

The codebase has established testify v1.9.0 as the standard testing library across _test.go files (imports found in 30+ test files including server/, mcp/, and client/ packages).

client/oauth_test.go currently uses manual if/t.Errorf() assertions in the new TestGetResourceMetadataURL and TestIsAuthorizationRequiredError tests. Refactor these to use testify/assert and/or testify/require for consistency:

  • Replace if condition { t.Errorf(...) } patterns with assert.Equal(), assert.Empty(), etc.
  • Use require for assertions that must pass to continue the test safely.

This aligns with the coding guideline and improves readability of test failures across the codebase.

client/transport/streamable_http_oauth_test.go (1)

287-334: Migrate test file to testify for consistency with codebase patterns

The codebase extensively uses testify/assert and testify/require across test files, but client/transport/streamable_http_oauth_test.go uses t.Errorf() throughout. The new TestExtractResourceMetadataURL test follows the existing pattern in this file, but to align with the coding guidelines and the established convention across the codebase, you should migrate this test file (or at minimum the new test) to use testify assertions.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ecc6d8f and 9ce87a0.

📒 Files selected for processing (9)
  • client/oauth.go (2 hunks)
  • client/oauth_test.go (1 hunks)
  • client/transport/oauth.go (2 hunks)
  • client/transport/sse.go (6 hunks)
  • client/transport/sse_oauth_test.go (1 hunks)
  • client/transport/streamable_http.go (4 hunks)
  • client/transport/streamable_http_oauth_test.go (1 hunks)
  • examples/oauth_client/README.md (3 hunks)
  • examples/oauth_client/main.go (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Order imports: standard library first, then third-party, then local packages (goimports enforces this)
Follow Go naming conventions: exported identifiers in PascalCase; unexported in camelCase; acronyms uppercase (HTTP, JSON, MCP)
Error handling: return sentinel errors, wrap with fmt.Errorf("context: %w", err), and check with errors.Is/As
Prefer explicit types and strongly-typed structs; avoid using any except where protocol flexibility is required (e.g., Arguments any)
All exported types and functions must have GoDoc comments starting with the identifier name; avoid inline comments unless necessary
Functions that are handlers or long-running must accept context.Context as the first parameter
Ensure thread safety for shared state using sync.Mutex and document thread-safety requirements in comments
For JSON: use json struct tags with omitempty for optional fields; use json.RawMessage for flexible/deferred parsing

Files:

  • client/oauth.go
  • examples/oauth_client/main.go
  • client/oauth_test.go
  • client/transport/sse.go
  • client/transport/streamable_http_oauth_test.go
  • client/transport/sse_oauth_test.go
  • client/transport/streamable_http.go
  • client/transport/oauth.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Testing: use testify/assert and testify/require
Write table-driven tests using a tests := []struct{ name, ... } pattern
Go test files must end with _test.go

Files:

  • client/oauth_test.go
  • client/transport/streamable_http_oauth_test.go
  • client/transport/sse_oauth_test.go
🧠 Learnings (1)
📚 Learning: 2025-10-13T09:35:20.180Z
Learnt from: CR
Repo: mark3labs/mcp-go PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-10-13T09:35:20.180Z
Learning: Applies to **/*_test.go : Testing: use testify/assert and testify/require

Applied to files:

  • client/oauth_test.go
🧬 Code graph analysis (7)
client/oauth.go (1)
client/transport/streamable_http.go (2)
  • AuthorizationRequiredError (266-268)
  • OAuthAuthorizationRequiredError (280-283)
examples/oauth_client/main.go (1)
client/oauth.go (1)
  • GetResourceMetadataURL (91-105)
client/oauth_test.go (3)
client/oauth.go (6)
  • OAuthAuthorizationRequiredError (64-64)
  • OAuthConfig (11-11)
  • AuthorizationRequiredError (61-61)
  • GetResourceMetadataURL (91-105)
  • IsAuthorizationRequiredError (67-70)
  • IsOAuthAuthorizationRequiredError (73-76)
client/transport/streamable_http.go (2)
  • OAuthAuthorizationRequiredError (280-283)
  • AuthorizationRequiredError (266-268)
client/transport/oauth.go (2)
  • NewOAuthHandler (157-169)
  • OAuthConfig (21-44)
client/transport/sse.go (2)
client/oauth.go (2)
  • AuthorizationRequiredError (61-61)
  • OAuthAuthorizationRequiredError (64-64)
client/transport/streamable_http.go (2)
  • AuthorizationRequiredError (266-268)
  • OAuthAuthorizationRequiredError (280-283)
client/transport/streamable_http_oauth_test.go (3)
client/oauth.go (5)
  • NewMemoryTokenStore (23-23)
  • Token (14-14)
  • OAuthConfig (11-11)
  • TokenStore (17-17)
  • OAuthAuthorizationRequiredError (64-64)
client/transport/oauth.go (4)
  • NewMemoryTokenStore (99-101)
  • Token (69-82)
  • OAuthConfig (21-44)
  • TokenStore (55-66)
client/transport/streamable_http.go (3)
  • NewStreamableHTTP (131-161)
  • WithHTTPOAuth (65-69)
  • OAuthAuthorizationRequiredError (280-283)
client/transport/sse_oauth_test.go (3)
client/transport/oauth.go (4)
  • NewMemoryTokenStore (99-101)
  • Token (69-82)
  • OAuthConfig (21-44)
  • TokenStore (55-66)
client/transport/sse.go (2)
  • NewSSE (85-112)
  • WithOAuth (77-81)
client/transport/streamable_http.go (1)
  • OAuthAuthorizationRequiredError (280-283)
client/transport/streamable_http.go (2)
client/oauth.go (2)
  • AuthorizationRequiredError (61-61)
  • OAuthAuthorizationRequiredError (64-64)
client/transport/oauth.go (1)
  • OAuthHandler (144-154)
🔇 Additional comments (9)
client/transport/sse_oauth_test.go (1)

243-304: SSE OAuth metadata discovery test exercises the right 401/error path

The test setup and assertions correctly mirror the SSE Start 401 handling (WWW-Authenticate → ResourceMetadataURL on OAuthAuthorizationRequiredError), and the use of a valid token plus forced 401 ensures the discovery path is actually hit. No changes needed here.

client/transport/streamable_http_oauth_test.go (1)

222-285: Streamable HTTP OAuth metadata discovery test aligns with 401 handling

The server fixture, 401 response, and assertion against OAuthAuthorizationRequiredError.ResourceMetadataURL line up with the new SendRequest 401 logic and give solid coverage of the RFC9728 path. Looks good.

client/transport/streamable_http.go (2)

240-277: AuthorizationRequiredError and metadata helper are well-structured for RFC9728

Defining ErrAuthorizationRequired, AuthorizationRequiredError (with ResourceMetadataURL), and the small extractResourceMetadataURL helper gives a clear, reusable surface for 401 handling. The Error/Unwrap methods follow the sentinel-error pattern, so callers can use both errors.Is(err, ErrAuthorizationRequired) and higher-level helpers like IsAuthorizationRequiredError/GetResourceMetadataURL.

Given the tests in client/transport/streamable_http_oauth_test.go, this looks solid.


330-349: Streamable HTTP 401 handling and OAuth error propagation look consistent

For both SendRequest and SendNotification, the new 401 branches:

  • Extract resource_metadata from WWW-Authenticate.
  • Return OAuthAuthorizationRequiredError with populated ResourceMetadataURL when oauthHandler is set.
  • Otherwise, return AuthorizationRequiredError with the same URL.

Additionally, sendHTTP now short-circuits when GetAuthorizationHeader returns ErrOAuthAuthorizationRequired by constructing an OAuthAuthorizationRequiredError (with empty metadata, since there’s no response yet). This matches how higher layers detect and drive the OAuth flow while still allowing GetResourceMetadataURL to succeed when a real 401 response provided metadata.

The behavior is coherent across request and notification paths; I don’t see issues here.

Also applies to: 433-445, 618-637

examples/oauth_client/main.go (1)

111-115: Example correctly surfaces discovered metadata URL via helper

Using client.GetResourceMetadataURL(err) inside the IsOAuthAuthorizationRequiredError branch is the right way to expose the RFC9728 resource_metadata URL to users, and the messaging makes the new behavior clear without changing the control flow. No further changes needed here.

client/transport/oauth.go (1)

35-38: ProtectedResourceMetadataURL wiring and discovery priority look correct

Adding ProtectedResourceMetadataURL to OAuthConfig with the updated getServerMetadata logic gives a clear precedence:

  1. Use AuthServerMetadataURL when explicitly configured.
  2. Otherwise, if ProtectedResourceMetadataURL is set (e.g., from RFC9728 resource_metadata), use that.
  3. Otherwise, fall back to baseURL + "/.well-known/oauth-protected-resource".

The subsequent fallbacks to authorization-server discovery and default endpoints remain intact. This aligns with the described RFC9728 behavior and should integrate cleanly with the new error-surfaced metadata.

Also applies to: 358-375

client/oauth.go (3)

60-61: LGTM! Clean exposure of transport-level error.

The type alias appropriately exposes the AuthorizationRequiredError at the client package boundary, following the same pattern as other convenience types in this file. The GoDoc comment is clear and properly formatted.


66-70: LGTM! Standard error checking helper.

The implementation correctly uses errors.As to check for AuthorizationRequiredError, following Go idioms and maintaining consistency with the existing IsOAuthAuthorizationRequiredError helper on lines 72-76.


87-105: LGTM! Well-implemented metadata URL extraction.

The function correctly extracts the ResourceMetadataURL from authorization errors with proper handling of both error types. The order of checking (OAuth-specific first, then base) is correct since OAuthAuthorizationRequiredError embeds AuthorizationRequiredError. The comprehensive GoDoc comment clearly explains the purpose and RFC9728 context. The function safely handles nil errors and missing metadata URLs by returning an empty string.

@ezynda3
Copy link
Contributor

ezynda3 commented Nov 22, 2025

I've reviewed the changes and found a critical logic gap that prevents the discovered metadata URL from actually being used.

  1. Discovered URL is Ignored: While the code extracts the resource_metadata URL into the error object, it is never communicated back to the OAuthHandler. getServerMetadata only checks h.config.ProtectedResourceMetadataURL. Since the OAuthHandler is long-lived and its config is immutable, the user (or the transport) has no way to update it with the URL found in the 401 response.
  2. sync.Once Limitation: getServerMetadata is guarded by h.metadataOnce. If metadata fetching was attempted (and failed or used defaults) before the 401 is processed (e.g. if the client tried to preemptively fetch it), setting the new URL won't trigger a re-fetch.
  3. Header Parsing: extractResourceMetadataURL uses simple string matching (strings.Index). This is fragile and could match substrings within other parameter values.

@coderabbitai what is your opinion on these findings, particularly regarding how we should safely update the OAuthHandler state given the sync.Once constraint?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 22, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the OAuthHandler implementation, specifically getServerMetadata
fd -e go . -x grep -l "type OAuthHandler" {} \; | head -5

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Examine the OAuthHandler struct and getServerMetadata method
ast-grep --pattern $'type OAuthHandler struct {
  $$$
}'

Length of output: 661


🏁 Script executed:

#!/bin/bash
# Find getServerMetadata implementation
ast-grep --pattern $'func ($$$) getServerMetadata($$$) ($$$) {
  $$$
}'

Length of output: 6895


🏁 Script executed:

#!/bin/bash
# Examine extractResourceMetadataURL implementation
ast-grep --pattern $'func extractResourceMetadataURL($$$) $$$'

Length of output: 1067


🏁 Script executed:

#!/bin/bash
# Check how the error flows - look at where OAuthAuthorizationRequiredError is created
rg -n -A5 "OAuthAuthorizationRequiredError" client/transport/streamable_http.go client/transport/sse.go

Length of output: 4885


🏁 Script executed:

#!/bin/bash
# Check the OAuthHandler's config field and how it's used in getServerMetadata
rg -n -B3 -A10 "func.*getServerMetadata" client/transport/oauth.go

Length of output: 533


🏁 Script executed:

#!/bin/bash
# Look at how metadataOnce is used
rg -n "metadataOnce" client/transport/oauth.go

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Check extractResourceMetadataURL full implementation
cat client/transport/streamable_http.go | sed -n '/^func extractResourceMetadataURL/,/^}/p'

Length of output: 387


🏁 Script executed:

#!/bin/bash
# Check the example to see how the discovered metadata URL is intended to be used
cat examples/oauth_client/main.go | sed -n '/GetResourceMetadataURL/,/^[[:space:]]*$/p'

Length of output: 237


🏁 Script executed:

#!/bin/bash
# Look for any mechanism to update handler config or reset sync.Once
rg -n "config.*=" client/transport/oauth.go | head -20

Length of output: 797


🏁 Script executed:

#!/bin/bash
# Check if there's any reset or update method on OAuthHandler
ast-grep --pattern $'func ($_ *OAuthHandler) $FUNC($$$) {
  $$$
}' | grep -E "(Update|Reset|Set|Reload)"

Length of output: 335


🏁 Script executed:

#!/bin/bash
# See the full error handling flow in the example
cat examples/oauth_client/main.go

Length of output: 6811


@ezynda3, your analysis is spot-on. All three concerns represent critical architectural issues in the current implementation. Let me address each:

1. Discovered URL is Never Used ✅ Valid

You're absolutely correct. The example demonstrates this gap perfectly:

if metadataURL := client.GetResourceMetadataURL(err); metadataURL != "" {
    fmt.Printf("Server provided OAuth metadata URL: %s\n", metadataURL)
    fmt.Println("The client will automatically use this URL for OAuth configuration.")
}

This comment is misleading. The URL is printed but never used. The code continues with the same oauthHandler that has:

  • An immutable config field (line 145 in oauth.go)
  • No mechanism to inject the discovered URL

The intended workflow appears to be:

  1. Catch the error
  2. Extract ResourceMetadataURL
  3. Create a new OAuthConfig with ProtectedResourceMetadataURL set
  4. Create a new client/transport
  5. Retry

But this is neither documented nor demonstrated in the example.

2. sync.Once Prevents Re-fetching ✅ Valid

The metadataOnce.Do() guard at line 351 ensures getServerMetadata only executes once. Even if we could update h.config.ProtectedResourceMetadataURL mid-flight, the metadata fetching logic would never re-run.

Additionally, getServerMetadata might be called before the 401 response is received (e.g., during initial token acquisition), meaning the discovered URL arrives too late.

3. Fragile Header Parsing ✅ Valid

The extractResourceMetadataURL function uses naive substring matching:

idx := strings.Index(wwwAuthHeader, prefix) // prefix = "resource_metadata=\""

This could match incorrectly if another parameter's value contains that substring:

WWW-Authenticate: Bearer realm="https://example.com?note=resource_metadata=\"tricky\"", resource_metadata="https://actual.url"

The parser would extract "https://example.com?note=resource_metadata=" instead of the actual URL.


Recommendations

For Issue #1 & #2: Architectural Fix

Option A: Pre-discovery Phase (Recommended)

  1. Add a separate discovery method to attempt fetching metadata before creating the handler
  2. Populate OAuthConfig.ProtectedResourceMetadataURL upfront
  3. Then create the handler with complete config

Option B: Resettable Metadata

// Add to OAuthHandler
func (h *OAuthHandler) UpdateProtectedResourceURL(url string) error {
    h.mu.Lock()
    defer h.mu.Unlock()
    
    h.config.ProtectedResourceMetadataURL = url
    h.serverMetadata = nil
    h.metadataFetchErr = nil
    h.metadataOnce = sync.Once{} // Reset the guard
    return nil
}

Option C: Lazy Initialization with Dynamic Config

  • Don't call getServerMetadata until absolutely needed
  • Check for discovered URL in error before first metadata fetch
  • Update config before the Once executes

For Issue #3: Robust Header Parsing

Replace the naive string search with proper RFC-compliant parsing:

func extractResourceMetadataURL(wwwAuthHeader string) string {
    // Split on commas, accounting for quoted values
    params := parseAuthParams(wwwAuthHeader)
    if url, ok := params["resource_metadata"]; ok {
        return url
    }
    return ""
}

func parseAuthParams(header string) map[string]string {
    params := make(map[string]string)
    // Implement proper RFC 7235 auth-param parsing
    // Handle quoted-strings, escape sequences, etc.
    // Consider using a library or regex with proper state machine
    return params
}

Or use regex with proper quoted-string handling:

func extractResourceMetadataURL(wwwAuthHeader string) string {
    // Match resource_metadata="..." accounting for escaped quotes
    re := regexp.MustCompile(`\bresource_metadata="([^"\\]*(?:\\.[^"\\]*)*)"`)
    matches := re.FindStringSubmatch(wwwAuthHeader)
    if len(matches) > 1 {
        return matches[1]
    }
    return ""
}

Immediate Action Items

  1. Fix the example to either:

    • Show creating a new client with discovered URL, OR
    • Remove the misleading "automatically use" comment
  2. Document the workflow explicitly in the README

  3. Consider implementing Option B (reset mechanism) as it's the least disruptive change

What's your preferred approach for addressing the state management issue with sync.Once?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 22, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Examine the OAuthHandler struct and its config field
echo "=== OAuthHandler struct ==="
ast-grep --pattern $'type OAuthHandler struct {
  $$$
}'

echo -e "\n=== getServerMetadata implementation ==="
ast-grep --pattern $'func ($_ *OAuthHandler) getServerMetadata() ($$$) {
  $$$
}'

echo -e "\n=== extractResourceMetadataURL implementation ==="
ast-grep --pattern $'func extractResourceMetadataURL($$$) string {
  $$$
}'

Length of output: 782


🏁 Script executed:

#!/bin/bash
# Check how the discovered URL flows through error handling
echo "=== Error creation in streamable_http.go ==="
rg -n -B2 -A8 "OAuthAuthorizationRequiredError\{" client/transport/streamable_http.go

echo -e "\n=== Error creation in sse.go ==="
rg -n -B2 -A8 "OAuthAuthorizationRequiredError\{" client/transport/sse.go

Length of output: 4082


🏁 Script executed:

#!/bin/bash
# Look at the example usage to see the intended workflow
echo "=== Example OAuth error handling ==="
sed -n '/if client.IsOAuthAuthorizationRequiredError(err)/,/^[[:space:]]*$/p' examples/oauth_client/main.go | head -30

Length of output: 167


🏁 Script executed:

#!/bin/bash
# Check if there's any mechanism to update or reset the handler
echo "=== OAuthHandler methods ==="
ast-grep --pattern $'func ($_ *OAuthHandler) $METHOD($$$) $$$'

Length of output: 28970


🏁 Script executed:

#!/bin/bash
# Examine the complete extractResourceMetadataURL implementation with context
echo "=== extractResourceMetadataURL with context ==="
rg -n -B5 -A15 "^func extractResourceMetadataURL" client/transport/streamable_http.go

Length of output: 856


@ezynda3, excellent analysis! All three concerns are architecturally critical and demonstrate a fundamental gap in the implementation. Let me validate each:

1. Discovered URL is Never Used ✅ Critical

Your assessment is spot-on. The flow is broken:

What happens:

  • Line 340 in streamable_http.go: metadataURL extracted from 401 response
  • Line 339-342: Placed into AuthorizationRequiredError{ResourceMetadataURL: metadataURL}
  • Error returned to caller
  • Dead end - no path back to OAuthHandler

The problem:

  • OAuthHandler.config is immutable (line 145: config OAuthConfig, not a pointer)
  • getServerMetadata only checks h.config.ProtectedResourceMetadataURL (line 369)
  • No setter method exists to update this field
  • The example's comment "automatically use this URL" is misleading

Actual workflow required (undocumented):

// Current code just prints it:
if metadataURL := client.GetResourceMetadataURL(err); metadataURL != "" {
    // Must create NEW client with updated config
    newConfig := originalConfig
    newConfig.ProtectedResourceMetadataURL = metadataURL
    newClient, _ := client.NewClient(/* ... */, client.WithOAuth(newConfig))
    // Retry with new client
}

2. sync.Once Prevents Re-fetching ✅ Critical

Line 351: h.metadataOnce.Do(func() { ensures metadata is fetched exactly once. Even if we could update h.config.ProtectedResourceMetadataURL mid-flight:

  • The Do() function body won't execute again
  • Metadata remains stale
  • No reset mechanism exists

Timing issue: If metadata fetch happens before the 401 (e.g., during initial token acquisition), the discovered URL arrives too late.

3. Fragile Header Parsing ✅ Valid

Lines 250-251:

idx := strings.Index(wwwAuthHeader, prefix)  // prefix = "resource_metadata=\""

Attack vector:

WWW-Authenticate: Bearer realm="https://evil.com?note=resource_metadata=\"injected\"", resource_metadata="https://real.com"

Returns: "https://evil.com?note=resource_metadata=" instead of "https://real.com"

Missing:

  • RFC 7235 quoted-string parsing
  • Escape sequence handling
  • Parameter boundary detection

Recommended Fixes

For Issues #1 & #2: Architecture

Option A: Pre-Discovery (Cleanest)

// Before creating handler
func DiscoverMetadataURL(ctx context.Context, serverURL string) (string, error) {
    // Make 401-triggering request, extract resource_metadata
    return metadataURL, nil
}

// Then use it
config.ProtectedResourceMetadataURL, _ = DiscoverMetadataURL(ctx, serverURL)
handler := NewOAuthHandler(config)

Option B: Resettable Metadata (Pragmatic)

func (h *OAuthHandler) UpdateProtectedResourceURL(url string) {
    h.config.ProtectedResourceMetadataURL = url
    h.serverMetadata = nil
    h.metadataFetchErr = nil
    h.metadataOnce = sync.Once{}  // Reset guard
}

Option C: Lazy + Dynamic Config

  • Pass discovered URL via context
  • Check context before metadataOnce.Do()
  • Defer metadata fetch until absolutely needed

For Issue #3: Robust Parsing

func extractResourceMetadataURL(wwwAuthHeader string) string {
    // RFC 7235 compliant: match resource_metadata="..." with proper quoted-string handling
    re := regexp.MustCompile(`\bresource_metadata\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"`)
    if matches := re.FindStringSubmatch(wwwAuthHeader); len(matches) > 1 {
        // Unescape per RFC 7230 Section 3.2.6
        return strings.ReplaceAll(matches[1], `\"`, `"`)
    }
    return ""
}

Or use a proper HTTP header parser library.


Immediate Actions

  1. Fix the example: Remove the misleading "automatically use" comment OR demonstrate creating a new client with discovered URL
  2. Document the workflow: Explicitly state the manual steps required
  3. Choose architecture fix: My recommendation is Option B (reset mechanism) as it's minimally invasive and backward-compatible

Question for you: Do you prefer Option B (adding a reset method), or would you rather implement Option A (pre-discovery phase) for a cleaner separation of concerns? Option A would be a larger refactor but architecturally superior.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 22, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Examine the OAuthHandler struct and its fields, especially around config and sync.Once
cat client/transport/oauth.go

Length of output: 23445


🏁 Script executed:

#!/bin/bash
# Look at the full error handling flow and where OAuthAuthorizationRequiredError is created/handled
cat client/transport/streamable_http.go | grep -A20 -B5 "OAuthAuthorizationRequiredError"

Length of output: 3493


🏁 Script executed:

#!/bin/bash
# Check the example to see the full error handling workflow
cat examples/oauth_client/main.go | grep -A30 -B10 "GetResourceMetadataURL"

Length of output: 1420


🏁 Script executed:

#!/bin/bash
# Examine extractResourceMetadataURL implementation
cat client/transport/streamable_http.go | sed -n '/^func extractResourceMetadataURL/,/^}/p'

Length of output: 387


🏁 Script executed:

#!/bin/bash
# Check if there's any mechanism to recreate or reset the handler
rg -n "NewOAuthHandler\|WithHTTPOAuth\|WithSSEOAuth" client/transport/ --type go -A3

Length of output: 0


@ezynda3, your analysis is absolutely correct on all three points. Let me address the sync.Once constraint specifically, as that's the most critical architectural flaw.

The sync.Once Problem: Why It's Fundamentally Broken

After examining the code and Go's sync.Once semantics, sync.Once isn't suitable for scenarios requiring reinitialization (e.g., reconnecting after discovering new metadata). Here's why the current implementation cannot work:

Timeline of the problem:

  1. Client attempts to connect → getServerMetadata() called
  2. metadataOnce.Do() executes, fetches metadata (or fails), locks forever
  3. Server returns 401 with WWW-Authenticate: Bearer resource_metadata="..."
  4. Extracted URL is placed in error → but it's too late
  5. Handler's getServerMetadata() will never run again

The Go stdlib has no Reset() - There is not a method to reset the state of sync.Once, and proposals to add one were rejected. Attempting to reset it manually is dangerous and can cause mutex corruption.

Root Cause: Architectural Mismatch

The RFC9728 discovery workflow is:

Try connection → 401 + metadata URL → Use metadata URL → Retry

But the current code assumes:

Fetch metadata once → Use forever

These are incompatible patterns.

Concrete Solutions

Option 1: Lazy Discovery (Recommended)

Don't call getServerMetadata() until absolutely needed AND check for discovered URL first:

func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetadata, error) {
    h.metadataOnce.Do(func() {
        // Check if we have a discovered URL from a previous 401 response
        if h.config.ProtectedResourceMetadataURL != "" {
            h.fetchMetadataFromURL(ctx, h.config.ProtectedResourceMetadataURL)
            return
        }
        
        // ... existing logic
    })
    
    return h.serverMetadata, h.metadataFetchErr
}

Problem: This only works if getServerMetadata() hasn't been called yet. If it was called during initial token fetch, you're locked out.

Option 2: Make OAuthHandler Recreatable

Since handlers can't be reset, make creating a new one cheap and easy:

// In client code after catching 401
if metadataURL := client.GetResourceMetadataURL(err); metadataURL != "" {
    // Create NEW handler with discovered URL
    newConfig := oauthConfig  // copy
    newConfig.ProtectedResourceMetadataURL = metadataURL
    
    // Replace handler in transport
    transport.UpdateOAuthHandler(NewOAuthHandler(newConfig))
    
    // Retry connection
    client.Connect(ctx)
}

Requires: Adding UpdateOAuthHandler() method to transports.

Option 3: Remove sync.Once Entirely

Replace with a mutex-protected lazy initialization that can be reset:

type OAuthHandler struct {
    config           OAuthConfig
    httpClient       *http.Client
    
    metadataMu       sync.RWMutex
    serverMetadata   *AuthServerMetadata
    metadataFetchErr error
    metadataFetched  bool  // NOT sync.Once
    
    // ...
}

func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetadata, error) {
    // Fast path: already fetched
    h.metadataMu.RLock()
    if h.metadataFetched {
        defer h.metadataMu.RUnlock()
        return h.serverMetadata, h.metadataFetchErr
    }
    h.metadataMu.RUnlock()
    
    // Slow path: fetch with write lock
    h.metadataMu.Lock()
    defer h.metadataMu.Unlock()
    
    // Double-check after acquiring write lock
    if h.metadataFetched {
        return h.serverMetadata, h.metadataFetchErr
    }
    
    // ... fetch logic ...
    h.metadataFetched = true
    return h.serverMetadata, h.metadataFetchErr
}

func (h *OAuthHandler) ResetMetadata() {
    h.metadataMu.Lock()
    defer h.metadataMu.Unlock()
    h.serverMetadata = nil
    h.metadataFetchErr = nil
    h.metadataFetched = false
}

func (h *OAuthHandler) UpdateProtectedResourceURL(url string) {
    h.config.ProtectedResourceMetadataURL = url
    h.ResetMetadata()  // Force re-fetch with new URL
}

This allows dynamic updates while maintaining thread safety.

Regarding the Fragile Header Parsing

The naive string matching with strings.Index needs proper RFC 7235 parsing:

func extractResourceMetadataURL(wwwAuthHeader string) string {
    // Use proper auth-param parsing
    re := regexp.MustCompile(`\bresource_metadata="([^"\\]*(?:\\.[^"\\]*)*)"`)
    matches := re.FindStringSubmatch(wwwAuthHeader)
    if len(matches) > 1 {
        // Handle escaped quotes in the URL
        return strings.ReplaceAll(matches[1], `\"`, `"`)
    }
    return ""
}

Recommended Path Forward

  1. Immediate: Fix the misleading example comment
  2. Short-term: Implement Option 3 (remove sync.Once) - it's the most robust
  3. Document: Clearly explain the workflow in the README
  4. Test: Add tests for the metadata-discovery-then-retry flow

Would you like me to open an issue tracking these architectural changes?

Add support for discovering OAuth authorization server metadata from
WWW-Authenticate headers per RFC9728 Section 5.1.

The MCP spec indicates that servers should return a 401 Unauthorized response
with a WWW-Authenticate header containing the resource_metadata parameter.
This parameter is used to discover the OAuth authorization server metadata.

This change adds support for this discovery, allowing clients to automatically
extract the OAuth metadata URL from the WWW-Authenticate header and use it to
discover the OAuth authorization server configuration, rather than relying
on it being on the /.well-known path of the base URL, which is not
always the case (for example,
https://mcp.linear.app/mcp/.well-known/oauth-protected-resource vs
https://mcp.honeycomb.io/.well-known/oauth-protected-resource - note the
lack of /mcp in one of these, even though both servers expect the /mcp
path in the base URL).

Changes:
- Add AuthorizationRequiredError base error type with ResourceMetadataURL field
- Add OAuthAuthorizationRequiredError that embeds AuthorizationRequiredError
- Add ProtectedResourceMetadataURL to OAuthConfig for explicit configuration
- Extract resource_metadata parameter from WWW-Authenticate headers in both
  streamable_http and sse transports
- Update getServerMetadata() to use ProtectedResourceMetadataURL when provided
- Add helper functions: IsAuthorizationRequiredError(), GetResourceMetadataURL()
- Add comprehensive tests for metadata URL extraction and usage
- Update OAuth example to demonstrate RFC9728 discovery

This allows clients to properly discover OAuth endpoints when servers return
401 responses with WWW-Authenticate headers containing resource_metadata URLs,
enabling correct OAuth flows without requiring well-known URL assumptions.

RFC9728: https://datatracker.ietf.org/doc/html/rfc9728
@sd2k sd2k force-pushed the oauth-allow-providing-protected-resource-url branch from 9ce87a0 to 238a36d Compare November 24, 2025 08:49
Copy link
Contributor

@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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/transport/oauth.go (1)

349-457: sync.Once prevents using discovered metadata URL.

getServerMetadata() is wrapped in h.metadataOnce.Do(), meaning it executes at most once. If metadata fetching occurs before receiving a 401 response with a resource_metadata parameter:

  1. The first call to getServerMetadata() completes (successfully or not).
  2. sync.Once prevents subsequent calls from re-executing the discovery logic.
  3. Even if a resource_metadata URL is later discovered and set in config (which currently isn't possible), it won't be used because Do() won't re-run.

To support RFC9728 discovery, replace sync.Once with a mutex-protected fetch flag and add methods like ResetMetadata() to allow safe re-fetching after discovery.

Based on PR comments discussion.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ce87a0 and 238a36d.

📒 Files selected for processing (9)
  • client/oauth.go (2 hunks)
  • client/oauth_test.go (1 hunks)
  • client/transport/oauth.go (2 hunks)
  • client/transport/sse.go (6 hunks)
  • client/transport/sse_oauth_test.go (1 hunks)
  • client/transport/streamable_http.go (4 hunks)
  • client/transport/streamable_http_oauth_test.go (1 hunks)
  • examples/oauth_client/README.md (3 hunks)
  • examples/oauth_client/main.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • client/transport/streamable_http_oauth_test.go
  • examples/oauth_client/main.go
  • client/transport/sse_oauth_test.go
🧰 Additional context used
📓 Path-based instructions (2)
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: Order imports: standard library first, then third-party, then local packages (goimports enforces this)
Follow Go naming conventions: exported identifiers in PascalCase; unexported in camelCase; acronyms uppercase (HTTP, JSON, MCP)
Error handling: return sentinel errors, wrap with fmt.Errorf("context: %w", err), and check with errors.Is/As
Prefer explicit types and strongly-typed structs; avoid using any except where protocol flexibility is required (e.g., Arguments any)
All exported types and functions must have GoDoc comments starting with the identifier name; avoid inline comments unless necessary
Functions that are handlers or long-running must accept context.Context as the first parameter
Ensure thread safety for shared state using sync.Mutex and document thread-safety requirements in comments
For JSON: use json struct tags with omitempty for optional fields; use json.RawMessage for flexible/deferred parsing

Files:

  • client/transport/streamable_http.go
  • client/oauth_test.go
  • client/oauth.go
  • client/transport/sse.go
  • client/transport/oauth.go
**/*_test.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*_test.go: Testing: use testify/assert and testify/require
Write table-driven tests using a tests := []struct{ name, ... } pattern
Go test files must end with _test.go

Files:

  • client/oauth_test.go
🧠 Learnings (1)
📚 Learning: 2025-10-13T09:35:20.180Z
Learnt from: CR
Repo: mark3labs/mcp-go PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-10-13T09:35:20.180Z
Learning: Applies to **/*_test.go : Testing: use testify/assert and testify/require

Applied to files:

  • client/oauth_test.go
🧬 Code graph analysis (3)
client/oauth_test.go (2)
client/oauth.go (6)
  • OAuthAuthorizationRequiredError (64-64)
  • OAuthConfig (11-11)
  • AuthorizationRequiredError (61-61)
  • GetResourceMetadataURL (91-105)
  • IsAuthorizationRequiredError (67-70)
  • IsOAuthAuthorizationRequiredError (73-76)
client/transport/streamable_http.go (2)
  • OAuthAuthorizationRequiredError (280-283)
  • AuthorizationRequiredError (266-268)
client/oauth.go (1)
client/transport/streamable_http.go (2)
  • AuthorizationRequiredError (266-268)
  • OAuthAuthorizationRequiredError (280-283)
client/transport/sse.go (2)
client/oauth.go (2)
  • AuthorizationRequiredError (61-61)
  • OAuthAuthorizationRequiredError (64-64)
client/transport/streamable_http.go (2)
  • AuthorizationRequiredError (266-268)
  • OAuthAuthorizationRequiredError (280-283)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: test
  • GitHub Check: coverage
  • GitHub Check: test
  • GitHub Check: coverage
🔇 Additional comments (5)
client/transport/streamable_http.go (3)

264-276: LGTM!

AuthorizationRequiredError is well-structured with proper Error() and Unwrap() methods following Go conventions. The ResourceMetadataURL field correctly captures RFC9728 metadata.


278-291: LGTM!

Embedding AuthorizationRequiredError in OAuthAuthorizationRequiredError is the correct approach, enabling errors.As to match both error types and allowing metadata URL extraction from OAuth-specific errors.


330-349: Error construction is consistent but extracted URL is not used.

The 401 handling correctly extracts resource_metadata and constructs errors with the URL in both SendRequest and SendNotification. The pattern is consistent:

  1. Call extractResourceMetadataURL() on WWW-Authenticate header.
  2. Return OAuthAuthorizationRequiredError (if handler exists) or AuthorizationRequiredError with the URL.
  3. Set empty URL when no response is available (line 442).

However, per the architectural issues flagged in oauth.go review, the discovered URL cannot currently be used because the config is immutable and sync.Once prevents metadata re-fetching.

The error construction is correct, but verify the end-to-end flow once the architectural issues are resolved.

Based on PR comments discussion.

Also applies to: 438-444, 619-638

client/transport/sse.go (1)

168-187: LGTM! Consistent 401 handling with metadata extraction.

The RFC9728 metadata discovery is consistently implemented across Start(), SendRequest(), and SendNotification():

  1. Extracts resource_metadata from WWW-Authenticate header.
  2. Returns appropriate error type based on OAuth handler presence.
  3. Embeds discovered URL in AuthorizationRequiredError.

The implementation matches streamable_http.go for consistency across transports. However, note the architectural issues flagged in oauth.go review: the discovered URL cannot currently be used due to immutable config and sync.Once guard.

Based on PR comments discussion.

Also applies to: 443-466, 579-598

client/oauth_test.go (1)

129-211: Tests verify helper functions but miss end-to-end discovery-then-retry flow.

The tests at lines 129-211 correctly verify GetResourceMetadataURL() and IsAuthorizationRequiredError() helper functions. Existing integration tests (TestStreamableHTTP_OAuthMetadataDiscovery and TestSSE_OAuthMetadataDiscovery) verify metadata extraction from the 401 response, but neither test verifies the complete workflow:

  1. Extract metadata URL from WWW-Authenticate header
  2. Fetch metadata from that URL
  3. Retry request with proper authorization

The ProtectedResourceMetadataURL field exists in OAuthConfig but no metadata fetching or automatic retry logic is currently implemented. Per PR discussion context mentioning architectural issues, adding this integration test may depend on completing the underlying discovery-then-retry mechanism.

Comment on lines +60 to +70
// AuthorizationRequiredError is returned when a 401 Unauthorized response is received
type AuthorizationRequiredError = transport.AuthorizationRequiredError

// OAuthAuthorizationRequiredError is returned when OAuth authorization is required
type OAuthAuthorizationRequiredError = transport.OAuthAuthorizationRequiredError

// IsAuthorizationRequiredError checks if an error is an AuthorizationRequiredError
func IsAuthorizationRequiredError(err error) bool {
var target *AuthorizationRequiredError
return errors.As(err, &target)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Helper functions are correct but API is incomplete for RFC9728 workflow.

The error helpers correctly extract the discovered metadata URL, but there's no corresponding API to apply the discovered URL:

  • GetResourceMetadataURL(err) extracts the URL.
  • No UpdateOAuthMetadataURL(client, url) or similar to reconfigure the handler.

For a complete RFC9728 implementation, add a helper to update the handler's ProtectedResourceMetadataURL and reset cached metadata, or document that users must create a new client with the discovered URL in the config.

Based on PR comments discussion.

Also applies to: 87-105

🤖 Prompt for AI Agents
In client/oauth.go around lines 60-70 (and also apply same change for the
related section at 87-105), the package exposes helpers to extract a discovered
Protected Resource Metadata URL from errors but lacks an API to apply that URL
to an existing client for RFC9728 flows; add a helper function (e.g.,
UpdateOAuthMetadataURL) that accepts the client instance and the discovered URL,
sets the client's ProtectedResourceMetadataURL field, and clears or resets any
cached metadata/memoized discovery state so the handler will reload metadata
from the new URL; ensure the helper validates the URL, updates the configuration
on the existing client safely (concurrent-safe if the client is used
concurrently), and document that callers should invoke this helper after
extracting the URL from GetResourceMetadataURL(err) to complete the RFC9728
workflow.

Comment on lines +35 to +38
// ProtectedResourceMetadataURL is the URL to the OAuth protected resource metadata
// per RFC9728. If set, this URL will be used to discover the authorization server.
// This is typically extracted from the WWW-Authenticate header's resource_metadata parameter.
ProtectedResourceMetadataURL string
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Configuration field cannot be updated after discovery.

ProtectedResourceMetadataURL is part of the immutable OAuthConfig. When a metadata URL is discovered from a WWW-Authenticate header (per RFC9728), there is no mechanism to update this field in the handler's config. The discovered URL is extracted into the error but cannot trigger a re-fetch of server metadata because:

  1. OAuthHandler.config is immutable.
  2. getServerMetadata() is guarded by sync.Once (line 351), preventing re-execution.

Consider adding UpdateProtectedResourceMetadataURL(url string) and ResetMetadata() methods to allow runtime discovery, or document that this field must be set at configuration time and discovery requires creating a new handler.

Based on PR comments discussion.

🤖 Prompt for AI Agents
In client/transport/oauth.go around lines 35 to 38, ProtectedResourceMetadataURL
is part of an immutable OAuthConfig so a metadata URL discovered at runtime
cannot be applied and getServerMetadata() is prevented from re-running by the
sync.Once guard; add runtime update support by (1) adding an exported method
UpdateProtectedResourceMetadataURL(url string) on the OAuthHandler that updates
a mutable field (or a small internal struct) holding the metadata URL, (2)
adding ResetMetadata() to clear any cached server metadata and reset the
sync.Once (or replace sync.Once with a mutex/atomic guard that supports
resetting), and (3) ensure getServerMetadata() reads the mutable metadata URL
and can re-fetch metadata after ResetMetadata() is called; alternatively,
document clearly that ProtectedResourceMetadataURL must be set at config time
and runtime discovery requires constructing a new handler.

authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx)
if err != nil {
// If we get an authorization error, return a specific error that can be handled by the client
if err.Error() == "no valid token available, authorization required" {
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use errors.Is instead of string comparison.

Lines 148 and 394 check err.Error() == "no valid token available, authorization required" which is fragile:

  1. String matching breaks if error message changes.
  2. Doesn't respect error wrapping.
  3. Inconsistent with line 553 which correctly uses errors.Is(err, ErrOAuthAuthorizationRequired).

Replace with:

-if err.Error() == "no valid token available, authorization required" {
+if errors.Is(err, ErrOAuthAuthorizationRequired) {

As per coding guidelines: "Error handling: return sentinel errors, wrap with fmt.Errorf, and check with errors.Is/As."

Also applies to: 394-394

🤖 Prompt for AI Agents
In client/transport/sse.go around lines 148 and 394, replace fragile string
equality checks of err.Error() == "no valid token available, authorization
required" with errors.Is(err, ErrOAuthAuthorizationRequired) so wrapped errors
are handled correctly; ensure the package imports the standard errors package if
not already imported and remove the string literal comparison, using the
sentinel ErrOAuthAuthorizationRequired (already used elsewhere) for consistent,
robust error checks.

Comment on lines +243 to +262
// extractResourceMetadataURL extracts the resource_metadata parameter from a WWW-Authenticate header
// per RFC9728 Section 5.1. Returns empty string if not found.
// Example: Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource"
func extractResourceMetadataURL(wwwAuthHeader string) string {
// Parse: Bearer resource_metadata="https://..." ...
const prefix = "resource_metadata=\""

idx := strings.Index(wwwAuthHeader, prefix)
if idx == -1 {
return ""
}

start := idx + len(prefix)
end := strings.Index(wwwAuthHeader[start:], "\"")
if end == -1 {
return ""
}

return wwwAuthHeader[start : start+end]
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Header parsing is fragile and not RFC-compliant.

extractResourceMetadataURL uses naive substring matching (strings.Index) which can incorrectly match:

  1. Substrings inside other parameter values (e.g., Bearer foo="has resource_metadata=\"inside\"")
  2. Parameters inside quoted strings with escapes
  3. Does not handle RFC 7235/7230 quoted-string semantics and escape sequences

Replace with proper auth-param parsing or a regex that handles quoted strings:

// Example regex approach:
var resourceMetadataRegex = regexp.MustCompile(`\bresource_metadata\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"`)

func extractResourceMetadataURL(wwwAuthHeader string) string {
    matches := resourceMetadataRegex.FindStringSubmatch(wwwAuthHeader)
    if len(matches) < 2 {
        return ""
    }
    // Unescape per RFC 7230 quoted-string rules
    return strings.ReplaceAll(matches[1], `\"`, `"`)
}

Based on PR comments discussion.

🤖 Prompt for AI Agents
In client/transport/streamable_http.go around lines 243-262, the current
substring-based parsing for resource_metadata is fragile and not RFC-compliant;
replace it with a robust auth-param parser using a regex that matches
resource_metadata with a quoted-string (e.g. a pattern like
\bresource_metadata\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)" ) to safely capture the
value, then unescape quoted-pair sequences per RFC 7230 (convert `\"` and other
backslash-escaped chars) before returning; ensure the function returns an empty
string when no match is found and add necessary imports (regexp, strings) and
unit tests for edge cases (escaped quotes, other params, missing param).

## Features

- OAuth 2.1 authentication with PKCE support
- **RFC9728 OAuth Protected Resource Metadata discovery** - Automatically discovers OAuth server metadata from `WWW-Authenticate` headers
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Documentation overstates automatic discovery capabilities.

The documentation claims the client "automatically discovers" and "automatically extracts" the OAuth metadata URL, but per the PR comments discussion, the discovered URL extracted from WWW-Authenticate is not actually propagated back to the OAuthHandler or used in subsequent requests. The OAuthHandler.config is immutable and getServerMetadata() is guarded by sync.Once, preventing re-fetching even if a metadata URL is discovered.

Clarify that the URL is extracted and made available via the error but requires manual reconfiguration to be used.

Based on PR comments discussion.

Also applies to: 29-32

🤖 Prompt for AI Agents
In examples/oauth_client/README.md around lines 8 and 29-32, the wording
incorrectly states that RFC9728 metadata is "automatically discovers" and
"automatically extracts"/used; update the documentation to state that the
metadata URL is only extracted from WWW-Authenticate and surfaced in the error
(made available) but is not applied to OAuthHandler.config automatically —
callers must manually reconfigure or reinitialize the handler to use the
discovered URL; change the phrasing to “extracts and exposes the metadata URL
via the error; manual reconfiguration is required to apply it” and add a short
note explaining why (immutable config / sync.Once prevents automatic re-fetch).

Comment on lines +67 to +78
This example demonstrates automatic OAuth metadata discovery per [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728). When the MCP server returns a 401 Unauthorized response with a `WWW-Authenticate` header containing the `resource_metadata` parameter, the client automatically extracts and uses this URL to discover the OAuth authorization server configuration.

The example code demonstrates this with:

```go
// Check if server provided OAuth metadata URL via WWW-Authenticate header (RFC9728)
if metadataURL := client.GetDiscoveredMetadataURL(err); metadataURL != "" {
fmt.Printf("Server provided OAuth metadata URL: %s\n", metadataURL)
}
```

This makes the OAuth flow more robust and standards-compliant, as the server explicitly tells clients where to find OAuth configuration rather than relying on well-known locations.
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Example code is misleading about automatic usage.

The example shows extracting the metadata URL but only prints it. It does not demonstrate how to actually use the discovered URL (e.g., creating a new client with ProtectedResourceMetadataURL configured). The statement that this "makes the OAuth flow more robust" is misleading since the discovered URL is not automatically applied to subsequent requests.

Show a complete example of using the discovered URL to reconfigure the client, or clarify that manual reconfiguration is required.

Based on PR comments discussion.

@sd2k
Copy link
Contributor Author

sd2k commented Nov 24, 2025

@ezynda3 roger, those points are valid, I think this is only working for us because we're using the API slightly weirdly (e.g. attempting to connect without OAuth first, so we already have an AuthorizationRequiredError when we initially construct the handler). I'll mark this as draft and come back to it soon.

@sd2k sd2k marked this pull request as draft November 24, 2025 08:56
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