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. Sessions that were
connected to the server under the previous configuration -- through
auth.forwardToken, or with a grant from the previous authorization server --
are put back to Not authenticated for it when the change is reconciled:
their next tool call answers auth_required with the sign-in link instead of
running, and muster auth login --server <name> signs them in.
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;
muster auth statuslists it as Not authenticated for the session. - Run
muster auth login --server <name>. The override skips PRM and the OAuth browser flow opens against the pinned issuer. A tool call made before that answersauth_requiredwith the same sign-in link (the answer is marked as an error: the tool did not run). Repeated calls of the session answer the same pending sign-in for up to 5 minutes, so a client that polls a tool spends one of the person's sign-in attempts, not one per call; a completed, expired or signed-out sign-in is replaced by a new one. - After the token is cached the server transitions to Connected; subsequent reconnects use the bearer header without rediscovery.
muster auth logout --server <name>signs the session out of that server alone -- its tools are hidden until the nextmuster auth login --server <name>-- while the session stays signed in to muster.muster auth logoutwithout--serverends the session with every server.
A backend that starts refusing the session's token mid-way (a revoked grant, a
server switched to another authorization server underneath a session
connected before) answers a tool call with 401; the session then gets the same
auth_required answer with the sign-in link, reads Not authenticated for
the server, and signs in again with muster auth login --server <name>. A
server connected through auth.forwardToken or auth.tokenExchange has no
sign-in of its own: its auth_required answer says to sign in to muster again.
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 is answeredauth_requiredwith that person's sign-in link on every call until they log 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 grant is revoked at the authorization server first (RFC 7009): its
refresh token, then its access token, at
revocationEndpointor, without one, therevocation_endpointits metadata advertises, identified as the client the grant was issued under. The provider then refuses both tokens (GitLab answers 401) and the next sign-in asks for consent again. A revocation that fails or times out (10 s) is logged (subject_grant_revocation_failed, with the issuer and the person) and does not keep the grant in muster; an authorization server without a revocation endpoint (GitHub) keeps the grant on its side until it expires, logged assubject_grant_revocation_unsupported. - 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: after a revocation it asks again; GitHub, which muster cannot revoke at, 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.
Two clients of one authorization server¶
The grant is filed under the pinned issuer, so two MCPServers that pin the
same issuer share one grant (the SSO reuse above). When they must not -- two
GitHub Apps, each with its own permissions, each consented to and signed out
of on its own -- pin each under its own identity and name the issuer the
authorization server really sends in expectedIssuer:
auth:
type: oauth
authorizationServer:
issuer: https://github.com/apps/second-app # the identity: the grant key
expectedIssuer: https://github.com/login/oauth # what GitHub puts in `iss`
authorizationEndpoint: https://github.com/login/oauth/authorize
tokenEndpoint: https://github.com/login/oauth/access_token
clientCredentialsSecretRef:
name: second-app-oauth-client
grantScope: subject
issueris the identity the pin files the grant under and nothing else is compared against it; any HTTPS URL unique to the App does, its App page being the natural choice. One consent, one refresh chain and onecore_auth_logoutper identity: signing out of one App leaves the other connected.expectedIssueris the issuer identifier the authorization server publishes and puts in the RFC 9207issparameter of its authorization responses --https://github.com/login/oauthfor GitHub. Without it the pinned identity is expected and GitHub's callback is refused; muster's log then saysiss mismatch: response carried "https://github.com/login/oauth", expected "https://github.com/apps/<slug>". The field needs the pinned endpoints and changes nothing about where the grant is stored, how it is refreshed or released; a response withoutissis accepted as before.- Both Apps share
authorizationEndpointandtokenEndpoint; each brings its ownclientCredentialsSecretRef. - Leave
scopesempty for a GitHub App: its permissions are configured on the App itself; ascopeparameter is for OAuth Apps.
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.
The person's ID token next to the pinned grant¶
A server that also calls back through muster as the person -- their Kubernetes
access on each installation, say -- needs the session's ID token besides the
pinned grant. Set auth.forwardIdentity: true next to authorizationServer:
Authorization keeps the pinned grant, and every request carries the
session's upstream ID token (the one a forwardToken server would receive) in
the X-Muster-Id-Token header, read per request so a refreshed token is used.
A session without an upstream ID token sends no header. requiredAudiences of
the server are requested at login, so the forwarded token carries them. As
with forwardToken, the token is not audience-scoped to the server: trust the
server as much as a forwardToken backend. See the
MCPServer reference.
Servers outside muster's namespace¶
With mcpServersInAllNamespaces, a server can live in an organization's
namespace. Its sign-in stays in that namespace: muster files its tokens, its
subject grants, its pin and its client registration under the namespace and the
issuer, so it shares nothing with the servers in muster's namespace or with
another namespace that names the same issuer. A server in muster's namespace
keeps its stored sign-ins.
- Its own client. Such a server signs in with a pin in its namespace
(
authorizationServerwithclientCredentialsSecretRef), or, when its authorization server offers RFC 7591, with a dynamic registration muster makes for that namespace. muster does not lend it its Client ID Metadata Document client, nor the pin of another namespace: without a pin and a registration endpoint,core_auth_loginrefuses the sign-in. A pin whose client ID is muster's own CIMD URL is refused too. - Its own redirect URI. The server signs in through
<publicURL>/oauth/ns/<namespace>/callback. Register that URI with its client (a HubSpot user creates the MCP connector with it). muster accepts an authorization response for the server only there, and refuses one that arrives on another namespace's callback or on the plain callback before any token endpoint receives the code. A new namespace needs a new redirect URI and a new client registration. - The plain callback is muster's namespace's. Register exactly
<publicURL>/oauth/proxy/callbackfor an app of muster's namespace, never a host root or a prefix: an authorization server that matches redirect URIs by prefix would otherwise accept a namespace's callback for it. WithmcpServersInAllNamespaceson, muster refuses to start whenoauth.mcpClient.callbackPathis a prefix of/oauth/ns/or starts with it. - Its resource. muster always checks that the resource the server declares is on the server's own origin, whatever document declares it; otherwise it uses the server's URL.
- One sign-in across surfaces. A pin with
grantScope: subjectkeeps the person's sign-in across the Dev Portal, Slack and agents, also with onlyissuerand a dynamic registration. Without it, the person signs in once per muster session. - Its client Secret, readable by muster by name only. The pin's
clientCredentialsSecretRefnames a Secret in the server's own namespace. The muster chart gives muster's ServiceAccount no read there, and must not:rbac.additionalSecretNamespaceswould give it every Secret of the namespace. Declare, with the server, a Role that gives mustergeton that one Secret, and its RoleBinding:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: muster-read-hubspot-client
namespace: org-sales
rules:
- apiGroups: [""]
resources: [secrets]
verbs: [get]
resourceNames: [hubspot-client]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: muster-read-hubspot-client
namespace: org-sales
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: muster-read-hubspot-client
subjects:
- kind: ServiceAccount
name: muster # muster's ServiceAccount
namespace: muster # muster's namespace
muster reads the Secret with one get by name, with no cache, so it needs no
list or watch. Without the Role, core_auth_login answers that the
server's sign-in is not ready, names the Secret and says which Role is
missing; the listings still show the server.
- Its sign-ins go with it. When the namespace's last server is deleted, or
the namespace goes, muster forgets everything it stored for the namespace:
the people's tokens and subject grants, the client registrations, the flows
in progress and the sessions' sign-in marks. When a server is deleted and
others stay, the issuers that only it named are forgotten. A server that
appears later in a namespace of the same name, or under the name of a
deleted server, starts with nothing, also when muster was down while the
old one went: muster tells servers apart by their Kubernetes UID. A restart
signs nobody out.
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. For the session's ID token next to the pinned grant, use
forwardIdentity (above).
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.