Skip to content

Authorization server selection is first-in-list for pre-provisioned providers #3844

Open

Description

@LHMisme420

With no issuer configured, _select_authorization_server returns advertised[0], and _preferred_authorization_server only filters by issuer when one is set, falling back to advertised[0] otherwise. So the resource server's list order decides which authorization server receives the pre-provisioned provider's Basic auth, which carries a durable client_secret rather than an expiring bearer.

Is first-in-list the intended selection when the list carries more than one server, and how would you classify a durable client secret placed with the first entry of a list the resource server controls?

Activity

  1. media-ctrl commented on Nov 9, 2026

    @media-ctrl

    The code answers the first question directly, and the second one is what the deprecation notice in the same file is about.

    _preferred_authorization_server in src/mcp/client/auth/extensions/client_credentials.py is:

    return next(
        (server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
    )
    

    So with issuer=None the fallback is advertised[0] by construction, not by accident. The base provider's _select_authorization_server in src/mcp/client/auth/oauth2.py is more explicit still: it returns advertised[0] with a comment saying the list is never empty. Both pre-provisioned providers override it (ClientCredentialsOAuthProvider and the private_key_jwt one) and both delegate to the same helper, so there is exactly one selection policy in the tree.

    Your classification question is answered by _checked_issuer in the same module: omitting issuer raises MCPDeprecationWarning with the text that the MCP server decides which authorization server receives the client's credentials, and that issuer becomes required in 3.0. The durable client_secret sent as Basic auth to a resource-server-chosen authorization server is precisely the hazard that deprecation exists to remove. I would classify it as a confused-deputy hazard rather than a bug: the resource server controls list order, that list is unauthenticated metadata, and a pre-provisioned credential that cannot be rotated per session is the highest-value asset in the whole flow.

    Two consequences, and both are decisions rather than defects:

    1. With issuer set, _require_metadata_for_configured_issuer drops discovered metadata that does not match the issuer, so the credential can only reach that issuer's token endpoint. That path already exists, which is why passing issuer is the entire fix and nothing else has to change.
    2. When the advertised list has more than one entry and no issuer is configured, first-in-list is ordering by resource-server preference. If you want a defensive default before 3.0 makes the choice mandatory, refusing to send a pre-provisioned credential while the list is longer than one and no issuer is set would fail closed instead of picking silently. RFC 9728 leaves the choice to the client, so this is policy, but this is the one place where a wrong pick produces no error surface at all.

    That last point is not theoretical for us: the connectors we run at corpusiq.io bind each connected account to its own authorization server, and the advertised list is exactly the field where a multi-tenant deployment could be pointed at the wrong tenant's token endpoint with nothing to show for it. Pinning issuer is the only thing that makes that structurally impossible rather than unlikely.

  2. LHMisme420 commented on Nov 9, 2026

    @LHMisme420
    Author

    Thanks for confirming it's construction rather than accident. Two readings then, and I want to get them right.

    Passing issuer is the fix and it's available now, since _require_metadata_for_configured_issuer already drops non-matching metadata. So the exposure is any deployment that hasn't set it, which is the default.

    Fail-closed on a multi-entry list with no issuer is the part not in the tree. Worth knowing whether that's a 3.0 plan or still open, because until it ships, the safe default is a config choice the operator has to know to make, and the deprecation warning is the only thing telling them

  3. media-ctrl commented on Nov 9, 2026

    @media-ctrl

    Requiring issuer is where the 3.0 note points. Once it is required, the selection fallback can still return advertised[0], but the credentials are only sent after _require_metadata_for_configured_issuer accepts metadata whose issuer matches, and that raises OAuthFlowError on a mismatch. The guard is what fails closed, not the selector.

    What the tree does not do today is refuse a multi-entry list while issuer is unset. It warns and returns None, so a client with several advertised servers and no issuer still takes the first. Whether a pre-3.0 hard error is added for that case is a maintainer call, not something the code answers.

    Until then your read holds. Passing issuer is the only path that constrains where the credentials go, and it works now because the metadata guard drops non-matching servers, so the deployment that sets it is already covered while the default one is not.

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

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions