Connecting to MCP servers that don't publish RFC 9728 metadata¶
Some hosted MCP servers (Atlassian's Remote MCP at mcp.atlassian.com is the
prompting case) require OAuth but don't publish RFC 9728 Protected
Resource Metadata at any well-known path. By default muster's core_auth_login
flow can't discover the authorization server for such backends and fails with:
Pin the issuer manually with spec.auth.authorizationServer:
apiVersion: muster.giantswarm.io/v1alpha1
kind: MCPServer
metadata:
name: atlassian
spec:
type: streamable-http
url: https://mcp.atlassian.com/v1/mcp
auth:
type: oauth
authorizationServer:
issuer: https://cf.mcp.atlassian.com
scopes: "openid offline_access"
When the override is set, muster skips PRM probing and fetches AS metadata
directly from <issuer>/.well-known/oauth-authorization-server. The discovered
issuer is verified against your pinned value per RFC 8414 §3.3
— a typo or stale URL fails closed instead of driving an OAuth flow against
the wrong AS.
Which issuer to pin¶
The pinned issuer is the authorization server the sign-in runs against
and the key the session's grant is filed under -- the browser flow, the
token store, the connection and core_auth_logout all use it. It takes
precedence over the authorization server the endpoint's own RFC 9728 metadata
names, so an endpoint that accepts tokens from an authorization server other
than the one it advertises is pinned by naming the accepting one. The issuer
may be muster's own identity provider (an endpoint that trusts the same Dex as
muster): muster keeps the session's login token apart from the grant, and a
sign-in against such a pin makes the server's tools callable like any other.
Name the scopes such an authorization server needs; a pin gets no default
scope, and Dex refuses a request without openid.
Changing authorizationServer -- another issuer, explicit endpoints added or
removed, a rotated client Secret reference -- takes effect on the next
core_auth_login. The description the MCPServer no longer carries is released
when the change is reconciled; nothing has to be restarted.
What you'll see in the UI¶
The override applies only to muster auth login --server <name>
(core_auth_login). It does not bypass the connect-time PRM probe that
runs when muster first reaches the backend, so:
- On first reconciliation the server enters Auth Required.
- Run
muster auth login --server <name>. The override skips PRM and the OAuth browser flow opens against the pinned issuer. - After the token is cached the server transitions to Connected; subsequent reconnects use the bearer header without rediscovery.
Authorization servers muster cannot discover or register with: GitHub¶
Some authorization servers go further than skipping RFC 9728: they publish no
RFC 8414 discovery document at all, accept neither Client ID
Metadata Documents nor RFC 7591 dynamic registration, and issue
tokens that belong to the person rather than to one login session. GitHub is
the prompting case -- the hosted GitHub MCP server at
https://api.githubcopilot.com/mcp/ names https://github.com/login/oauth as
its authorization server, and any GitHub user token is accepted as its bearer.
Three optional fields on spec.auth.authorizationServer describe such a
server completely:
apiVersion: muster.giantswarm.io/v1alpha1
kind: MCPServer
metadata:
name: github
namespace: agent-platform
spec:
type: streamable-http
url: https://api.githubcopilot.com/mcp/
auth:
type: oauth
authorizationServer:
issuer: https://github.com/login/oauth
# No discovery document: pin the endpoints. Both or neither.
authorizationEndpoint: https://github.com/login/oauth/authorize
tokenEndpoint: https://github.com/login/oauth/access_token
scopes: "repo read:org project"
# A client registered with GitHub out of band (a GitHub App or OAuth
# App whose callback URL is muster's /oauth/proxy/callback).
clientCredentialsSecretRef:
name: github-oauth-client # keys: client-id, client-secret
# The grant belongs to the person, not to one login session.
grantScope: subject
authorizationEndpoint/tokenEndpointreplace discovery for this issuer. muster performs no metadata request and assumes S256 PKCE support (it keeps sendingcode_challenge; an AS that ignores it is unaffected). The operator pinning the endpoints vouches for them, which is why the RFC 8414 §3.3 self-check of the plain issuer override does not apply here.clientCredentialsSecretRefmakes muster identify itself with the pre-registered client instead of its CIMD URL or a dynamic registration:core_auth_loginreportsclientIdMethod: preregistered, and token refresh presents the same client. Rotate the secret and the next login picks it up; no restart needed.grantScope: subjectfiles every token from this issuer under the user's identity (thesubmuster derives from the caller's token) as well as under the session. A later session of the same person -- another MCP client, a re-login, a front-end whose bearer rotates on every token refresh -- reuses the grant without callingcore_auth_login: its first tool call to the server connects with the grant and runs, andlist_toolsshows the server's tools instead of listing the server underauth_required. Acore_auth_loginfrom such a session still works and answers "Successfully connected" without a sign-in link. A session of a person without a grant keeps failing withuser not authenticated to server <name>until that person logs in once. Use it only for external accounts the person owns; a token that carries session-specific authority stayssession, the default.
Several MCPServers may point at the same issuer (GitHub's hosted MCP server and an in-house server that verifies GitHub tokens, say): the grant is stored per issuer, so one consent serves them all -- the usual SSO reuse.
Signing out of a subject-scoped issuer¶
The grant belongs to the person, so core_auth_logout on any server of a
subject-scoped issuer revokes it for the person, not for one server or one
session -- even when other MCPServers share the issuer, where a logout from a
session-scoped shared issuer would leave the token alone so the other servers
keep working. Concretely:
- The tokens for that issuer are deleted from every session of the person and
from the person's grant itself.
muster auth statusshows every server of the issuer as not authenticated. - Every server of the issuer is disconnected for the calling session and for the person's other live sessions muster knows about: their tools are hidden and their pooled connections are closed. The tool result names the sibling servers it disconnected. A session muster has not seen since it started (or one on another replica) finds the token gone on its next request and disconnects itself; no session can adopt a revoked grant.
- The next
core_auth_loginon any of those servers starts a fresh sign-in. Whether the browser sees a consent prompt is the authorization server's call: GitHub redirects straight back while the person's authorization of the App still exists on the GitHub side, so revoke it there too if the account must be cut off from muster for good.
"Sign out everywhere" removes the grant as well; logging one session out of muster does not.
Keeping the grant alive: refresh¶
Access tokens from such an authorization server are short-lived (a GitHub App's user access token lives eight hours) and come with a refresh token (GitHub's lives six months and is rotated on every use). muster refreshes a subject-scoped grant itself, so the person connects once and the grant then lives as long as the refresh token does, across every session and muster restart:
- The grant filed under the person is the canonical copy. Every lookup that
serves a tool call, a
core_auth_loginor a broker release checks it first; when its access token has expired or will within the refresh margin (five minutes, capped at half the token's lifetime), muster redeems the refresh token at the pinnedtokenEndpointwith the pre-registered client fromclientCredentialsSecretRef, stores the rotated tokens under the person and under every session copy, and serves the new access token. A running connection presents the new bearer on its next request; nothing reconnects. - Refreshes are single-flighted per person and issuer, and the store is re-read inside the flight, so many sessions and tool calls arriving at once cost one token request -- which matters because a rotating refresh token can be redeemed exactly once.
- A refresh the authorization server rejects (
invalid_grant, GitHub'sbad_refresh_token: the person revoked the App, the refresh token expired) removes the grant for the person; the next use asks for a sign-in, which GitHub answers with a consent page again. A transient failure (network, 5xx) keeps the grant and the still-valid access token and is retried on the next lookup. Both are logged assubject_grant_refresh_rejected/subject_grant_refresh_failed; a refresh that went through logssubject_grant_refreshed.
Session-scoped tokens (the default) are not touched by this: their refresh stays with the MCP transport as before.
Releasing a person's grant to a trusted relying party¶
A front-end that talks to the same external account with its own client
libraries -- a developer portal whose GitHub plugins carry their own GitHub
clients -- needs the person's access token in its own hands, not a tool call
through muster. The token broker (tokenExchangeBroker, see the
configuration reference)
can release a subject-scoped grant to such a party through the standard
RFC 8693 token exchange it already serves, with a grant target:
aggregator:
oauth:
server:
trustedIssuers:
- issuer: https://dex.example.com # the portal's login IdP
jwksUrl: https://dex.example.com/keys
allowedAudiences: ["portal-client-id"]
tokenExchangeBroker:
clientAudiences:
portal-backend: ["github"]
brokerClients:
portal-backend:
clientCredentialsSecretRef:
name: muster-broker-clients
targets:
github:
# The issuer the MCPServer above pins with grantScope: subject.
grantIssuer: https://github.com/login/oauth
The relying party's backend (a confidential broker client) POSTs to
/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
the person's ID token as subject_token and audience=github, and receives:
{"access_token": "ghu_…", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer", "expires_in": 27540}
- The person is the subject token's own
sub-- the Dex subject the grant was filed under when the person connected the server -- regardless of asubjectClaimmapping the trusted issuer may carry for on-behalf-of exchanges. - The access token is the person's, refreshed first when it is due;
expires_inis its remaining lifetime. The refresh token never leaves muster: the relying party re-exchanges when the token runs out, and each release is audited (subject_grant_released) like every exchange. - A person without a grant gets
invalid_target. The relying party then sends the person through muster's connect once:core_auth_loginfor the server answers the sign-in URL, and the start endpoint'sredirect=parameter (oauth.mcpClient.postLoginRedirectAllowlist) brings the browser back to the page that needed the token -- for an App the person already authorized at their login, GitHub redirects straight back without a prompt. clientAudiencesgates which client may ask for which grant; a grant target takes none of the Dex exchange settings (connectorId,scopes,clientCredentialsSecretRef), and a target is either a grant target or a Dex exchange target, never both.- Signing out (
core_auth_logouton any server of the issuer, "sign out everywhere") revokes the grant for the relying party as well: its next exchange answersinvalid_target.
When not to use this¶
authorizationServer is mutually exclusive with forwardToken: true and
tokenExchange.enabled: true. The CRD admission rules will reject any
MCPServer that combines them — those features have their own issuer
configuration.
The override does not change the RFC 8707 resource parameter
sent on auth/token requests. It remains the configured MCP server URL with the
query and the fragment dropped, which is also what mcp-go sends on token
refresh.
Reporting non-compliant backends¶
If you find a hosted MCP that doesn't publish RFC 9728 PRM, please file an issue with that backend's vendor — the MCP authorization spec mandates publishing PRM. The override exists as an operator escape hatch, not as a substitute for spec compliance.