Skip to content

[v2] RFC 9728 PRM URLs and resource matching drop query components #3065

Description

@mayankbohradev

What happened?

While testing the v2 auth/protected-resource-metadata path, I noticed query-bearing resource identifiers are treated as if the query is not part of the resource.

For a resource server URL like:

https://api.example.com/mcp?tenant=a

three SDK paths currently drop or ignore ?tenant=a:

  1. mcp.server.auth.routes.build_resource_metadata_url() returns:
https://api.example.com/.well-known/oauth-protected-resource/mcp
  1. mcp.client.auth.utils.build_protected_resource_metadata_discovery_urls(None, resource) also tries:
https://api.example.com/.well-known/oauth-protected-resource/mcp
https://api.example.com/.well-known/oauth-protected-resource
  1. mcp.shared.auth_utils.check_resource_allowed() treats different query components as matching, so the client accepts protected resource metadata for ?tenant=b when the server URL was ?tenant=a.

This is latent for path-only deployments, but it matters for query-routed or multi-tenant resource identifiers.

What did you expect?

RFC 9728 derives the protected-resource metadata URL by inserting /.well-known/oauth-protected-resource before the protected resource path and/or query. If the resource identifier includes a query component, the derived metadata URL and resource validation should not silently collapse it with a different query.

For https://api.example.com/mcp?tenant=a, I expected the path-specific metadata URL to be:

https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a

And a PRM document whose resource is https://api.example.com/mcp?tenant=b should not validate for a client configured with https://api.example.com/mcp?tenant=a.

Code to reproduce

import anyio
from pydantic import AnyHttpUrl, AnyUrl

from mcp.client.auth import OAuthClientProvider
from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls
from mcp.server.auth.routes import build_resource_metadata_url
from mcp.shared.auth import OAuthClientMetadata, ProtectedResourceMetadata
from mcp.shared.auth_utils import check_resource_allowed
from tests.interaction.auth._harness import InMemoryTokenStorage


async def main() -> None:
    resource = "https://api.example.com/mcp?tenant=a"

    print(build_resource_metadata_url(AnyHttpUrl(resource)))
    print(build_protected_resource_metadata_discovery_urls(None, resource))
    print(check_resource_allowed(
        "https://api.example.com/mcp?tenant=a",
        "https://api.example.com/mcp?tenant=b",
    ))

    provider = OAuthClientProvider(
        server_url=resource,
        client_metadata=OAuthClientMetadata(
            client_name="probe",
            client_uri=AnyHttpUrl("https://example.com"),
            redirect_uris=[AnyUrl("http://localhost:3030/callback")],
        ),
        storage=InMemoryTokenStorage(),
    )
    prm = ProtectedResourceMetadata(
        resource=AnyHttpUrl("https://api.example.com/mcp?tenant=b"),
        authorization_servers=[AnyHttpUrl("https://auth.example.com")],
    )
    await provider._validate_resource_match(prm)
    print("accepted mismatched query")


anyio.run(main)

Current output:

https://api.example.com/.well-known/oauth-protected-resource/mcp
['https://api.example.com/.well-known/oauth-protected-resource/mcp', 'https://api.example.com/.well-known/oauth-protected-resource']
True
accepted mismatched query

SDK version

Current main branch, v2 development line.

Area

Auth

AI-assisted (Claude/Codex) for navigation and review; change authored and understood by me.

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    on Jul 6, 2026
  2. AlvaroBalbin commented on Jul 21, 2026

    @AlvaroBalbin

    I can reproduce this. All three spots build from urlparse(...).path and never touch .query: build_resource_metadata_url in server/auth/routes.py, the path-based branch of build_protected_resource_metadata_discovery_urls in client/auth/utils.py, and check_resource_allowed in shared/auth_utils.py, which only compares scheme, netloc and path before the hierarchical prefix check that _validate_resource_match relies on.

    Worth noting resource_url_from_server_url already keeps the query (it strips only the fragment), so by the time the mismatched PRM reaches check_resource_allowed the query is present on both sides and simply ignored. That makes the comparison the localized gap.

    One design question for whoever picks this up: the path match is intentionally hierarchical (a parent-path token works for child paths), but a query component isn't hierarchical, so I don't think you can fold it into the same startswith compare. Do we want exact query equality as an added condition, matching the simple-string comparison used elsewhere in this module for issuer checks?

  3. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    needs confirmationNeeds confirmation that the PR is actually required or needed.
    authIssues and PRs related to Authentication / OAuth
    v1Affects the v1.x maintenance line
    on Aug 14, 2026
  4. slowe89 commented on Sep 10, 2026

    @slowe89

    Related seam we keep hitting: clients that canonicalize/compare PRM resource without preserving query components fail against servers that put tenant/session state in the query, even when Inspector’s happy path looked fine. Same family as trailing-slash / path mismatch — Inspector-green, shipping-client-red.

    We’re collecting those client/server PRM identity mismatches in a compatibility scorecard if useful: https://compatlab-waitlist.vercel.app (permission-only).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.v1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions