Skip to content

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:

Cannot authenticate to '<server>': RFC 9728 protected resource metadata not found.

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:

  1. On first reconciliation the server enters Auth Required; muster auth status lists it as Not authenticated for the session.
  2. 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 answers auth_required with 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.
  3. After the token is cached the server transitions to Connected; subsequent reconnects use the bearer header without rediscovery.
  4. muster auth logout --server <name> signs the session out of that server alone -- its tools are hidden until the next muster auth login --server <name> -- while the session stays signed in to muster. muster auth logout without --server ends 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 / tokenEndpoint replace discovery for this issuer. muster performs no metadata request and assumes S256 PKCE support (it keeps sending code_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.
  • clientCredentialsSecretRef makes muster identify itself with the pre-registered client instead of its CIMD URL or a dynamic registration: core_auth_login reports clientIdMethod: preregistered, and token refresh presents the same client. Rotate the secret and the next login picks it up; no restart needed.
  • grantScope: subject files every token from this issuer under the user's identity (the sub muster 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 calling core_auth_login: its first tool call to the server connects with the grant and runs, and list_tools shows the server's tools instead of listing the server under auth_required. A core_auth_login from such a session still works and answers "Successfully connected" without a sign-in link. A session of a person without a grant is answered auth_required with 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 stays session, 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 revocationEndpoint or, without one, the revocation_endpoint its 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 as subject_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 status shows 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_login on 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_login or 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 pinned tokenEndpoint with the pre-registered client from clientCredentialsSecretRef, 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's bad_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 as subject_grant_refresh_rejected / subject_grant_refresh_failed; a refresh that went through logs subject_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
  • issuer is 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 one core_auth_logout per identity: signing out of one App leaves the other connected.
  • expectedIssuer is the issuer identifier the authorization server publishes and puts in the RFC 9207 iss parameter of its authorization responses -- https://github.com/login/oauth for GitHub. Without it the pinned identity is expected and GitHub's callback is refused; muster's log then says iss 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 without iss is accepted as before.
  • Both Apps share authorizationEndpoint and tokenEndpoint; each brings its own clientCredentialsSecretRef.
  • Leave scopes empty for a GitHub App: its permissions are configured on the App itself; a scope parameter 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 a subjectClaim mapping the trusted issuer may carry for on-behalf-of exchanges.
  • The access token is the person's, refreshed first when it is due; expires_in is 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_login for the server answers the sign-in URL, and the start endpoint's redirect= 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.
  • clientAudiences gates 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_logout on any server of the issuer, "sign out everywhere") revokes the grant for the relying party as well: its next exchange answers invalid_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 (authorizationServer with clientCredentialsSecretRef), 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_login refuses 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/callback for 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. With mcpServersInAllNamespaces on, muster refuses to start when oauth.mcpClient.callbackPath is 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: subject keeps the person's sign-in across the Dev Portal, Slack and agents, also with only issuer and 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 clientCredentialsSecretRef names a Secret in the server's own namespace. The muster chart gives muster's ServiceAccount no read there, and must not: rbac.additionalSecretNamespaces would give it every Secret of the namespace. Declare, with the server, a Role that gives muster get on 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.