Skip to content

feat(mcp): OAuth discovery (RFC 9728/8414) + dynamic client registration (RFC 7591) (closes #316)#320

Merged
initializ-mk merged 3 commits into
mainfrom
feat/mcp-oauth-discovery-dcr
Jul 16, 2026
Merged

feat(mcp): OAuth discovery (RFC 9728/8414) + dynamic client registration (RFC 7591) (closes #316)#320
initializ-mk merged 3 commits into
mainfrom
feat/mcp-oauth-discovery-dcr

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Closes #316.

What

Remote-MCP OAuth stops requiring the operator to pre-register a client and hand-type endpoints. Given only transport: http + url:, Forge derives everything via the MCP Authorization spec — the same zero-config path Claude Code and the reference clients use.

# before: 3 hand-configured fields + a manually registered OAuth app
# after:
- name: linear
  transport: http
  url: https://mcp.linear.app/mcp
  auth:
    type: oauth
    scopes: [read, write]   # client_id + endpoints discovered
  tools: { allow: [create_issue, list_issues] }

How

Step RFC What
Protected-resource metadata 9728 from the 401 WWW-Authenticate resource_metadata pointer, or {origin}/.well-known/oauth-protected-resource → the authorization server
Auth-server metadata 8414 .well-known/oauth-authorization-server (OpenID openid-configuration fallback) → authorize/token/registration endpoints + scopes_supported
Dynamic client registration 7591 mint a client_id at first forge mcp login; persisted and reused on refresh — never re-minted

Precedence & safety

  • Explicit config always wins — set client_id/authorize_url/token_url to override discovery.
  • Resolution order: explicit → persisted registration → discovery+DCR. Discovery only runs at interactive login; the refresh path never mints a client (allowDiscovery=false).
  • A partial endpoint pair (one set, the other empty) is a validation error.
  • Fail-closed with a clear message when a server advertises no metadata / no registration_endpoint and no client_id is configured.
  • Persistence: new generic oauth.SaveRecord/LoadRecord/DeleteRecord (encrypted-preferred, plaintext fallback — mirrors the token store) hold the discovered endpoints + minted client under mcp_reg_<name>, so refresh and a pod restart reuse them.
  • Egress: the discovered auth-server host isn't in forge.yaml to pre-seed the allowlist, so mcp.RegisteredOAuthHosts reads it back from the login-time registration record and the runtime merges it into the egress allowlist (store-path override applied first). Discovery itself runs laptop-side, off the egress-enforced path.

Acceptance criteria

  • Zero-config (type: oauth + url:) completes login against a spec-compliant server.
  • RFC 9728 discovery from both WWW-Authenticate and the well-known path.
  • RFC 8414 metadata (with OpenID fallback).
  • RFC 7591 DCR; minted client persisted + reused (no re-registration per run).
  • Explicit config still honored and overrides discovery; NewServer/ValidateMCPConfig guards relaxed (partial pair still rejected).
  • Discovery/token/registration ride the egress-controlled client; discovered hosts learned into the allowlist.
  • Fail-closed when no metadata / no registration_endpoint and no static config.
  • Docs updated (mcp/configuration.md, forge.md).
  • Tests: metadata parse (both well-knowns + WWW-Authenticate), DCR round-trip + persistence/reuse, static-override precedence, no-metadata fail-closed, refresh-never-discovers.

Notes

  • Docs example now points at /mcp (Streamable HTTP) instead of the legacy /sse.
  • Out of scope / follow-up: confidential-client secrets from DCR are persisted but the token path stays public-client PKCE (doesn't send client_secret yet); build-time egress freezing can't know discovery hosts (runtime allowlist covers it). This lands the discovery config seam that the method:-based reshape (per design-tool-registry.md §18) will build on.

All three modules build; golangci-lint clean; forge-core mcp/validate/oauth + forge-cli runtime/cmd suites pass.

…ion (RFC 7591) (closes #316)

Remote-MCP OAuth no longer requires hand-configuring the client and
endpoints. Given only `transport: http` + `url:`, Forge derives
everything the way every other MCP client does:

- RFC 9728 — protected-resource metadata (from the 401 WWW-Authenticate
  `resource_metadata` pointer, or the origin's
  `.well-known/oauth-protected-resource`) → the authorization server.
- RFC 8414 — auth-server metadata (`oauth-authorization-server`, with the
  OpenID `openid-configuration` variant as fallback) → authorize/token/
  registration endpoints + scopes_supported.
- RFC 7591 — dynamic client registration mints a client_id at first
  `forge mcp login`; persisted (encrypted, alongside the token) and
  reused on refresh — never re-minted per run.

Config: `client_id`/`authorize_url`/`token_url` are now OPTIONAL for
`type: oauth`. Precedence: explicit config wins; else a persisted
registration; else discovery+DCR (login only — the refresh path never
mints). A partial endpoint pair (one set, one empty) is a validation
error. Fail-closed with a clear message when a server advertises no
metadata / no registration_endpoint and no client_id is set.

Persistence: new generic `oauth.SaveRecord/LoadRecord/DeleteRecord`
(encrypted-preferred, plaintext fallback, mirroring the token store)
hold the discovered endpoints + minted client under `mcp_reg_<name>`.

Egress: the discovered authorization-server host isn't in forge.yaml to
pre-seed the allowlist, so `mcp.RegisteredOAuthHosts` reads it back from
the login-time registration record and the runtime merges it into the
egress allowlist (store-path override applied first).

Also: docs example now points at `/mcp` (Streamable HTTP) rather than the
legacy `/sse`.

Wired through `OAuthFlow.Login` + `BearerToken` (new `resolveOAuthConfig`);
`NewServer`/`ValidateMCPConfig` guards relaxed to allow discovery while
still rejecting partial endpoint configs.

Tests: 9728/8414/7591 happy path + persistence-and-reuse (one DCR call),
WWW-Authenticate fallback, explicit-override precedence, no-registration
fail-closed, refresh-never-discovers, RegisteredOAuthHosts, and the
WWW-Authenticate / well-known parse helpers. Docs: mcp/configuration.md
discovery section; forge.md MCP note.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: MCP OAuth discovery (RFC 9728/8414) + DCR (RFC 7591)

Strong zero-config feature and the RFC layering is faithful (9728 protected-resource → 8414 auth-server metadata w/ OpenID fallback → 7591 DCR). I verified the parts that matter: the validation relaxation is clean (client_id + endpoints now optional; a partial endpoint pair is still an XOR error, tested both directions), Login passes allowDiscovery=true while BearerToken/refresh passes false, port-less loopback DCR redirect is the correct RFC 8252 §7.3 approach, and the discovered-host egress learn-back is sound. CI fully green.

Three findings (all confirmed against the code), then incremental minors:

1. Medium (security): a DCR confidential-client secret can land in a plaintext store — and it is currently persisted for no functional benefit

Confirmed the full chain: registerClient parses client_secret from the DCR response → resolveOAuthConfig stores it into oauthRegistration.ClientSecret (oauth_discovery.go:143) → oauth.SaveRecord (line 149) → SaveRecord falls through to saveRecordPlaintext (store.go:174) whenever no encrypted backend is configured. So on a host with no keyring/passphrase, an AS-issued confidential secret is written in clear to mcp_reg_<name>.json. "Encrypted-preferred, plaintext-fallback" is fine for a client_id; it is not fine for a secret.

What makes this cleaner to fix than it looks: the PR body itself notes the token path stays public-client PKCE and doesn't send client_secret yet. So the secret is persisted and unused — pure liability with zero current benefit. Simplest fix: don't persist ClientSecret at all today (drop it from the record before SaveRecord); wire secret handling — encryption-required — when the confidential token path actually lands. If you'd rather keep persisting it, fail closed on the plaintext path when a secret is present (refuse to write, error out). This matters more under admission-time DCR, not less: the platform mints and holds those secrets, so Forge should never be their store of record. Inline on oauth_discovery.go:143.

2. Docs (small): reframe "explicit config = override"

The docs frame explicit config as the override for servers that don't advertise metadata / don't support DCR (configuration.md:106). That's now incomplete — explicit is also the managed path: the normal case when a platform materializes config from a registry entry. Reframe as: discovery is the standalone default; explicit config is both the static override AND the platform-materialized path.

3. Test: assert the partial-materialization fail-closed (it's emergent, not asserted)

Verified the invariant holds but only emergently: on refresh, resolveOAuthConfig step-1 needs all three fields, step-2 finds no record in a fresh pod, and !allowDiscovery (oauth_discovery.go:110) returns ErrNoToken → fails closed, never mints. Good — but nothing pins it. The precise nuance to lock down: on the refresh/runtime path, a partially-materialized config (a platform env var came through empty) must fail closed and never mint a divergent client. (Note the asymmetry worth a comment: the login path with the same partial config would fall through to DCR and mint its own client — fine for laptop login, but it means "partial ⇒ fail-closed" is a refresh-path property specifically.) Cheap test, pins the drift scenario. Inline on the gate.

Incremental minors

  • authorization_servers[0] only (discoverProtectedResource): RFC 9728 advertises a list, but discovery tries only the first — no fallback to [1] if the first AS's 8414 metadata fetch fails. Minor robustness gap; a multi-AS resource with a dead primary won't complete.
  • Server-controlled redirect at login rides the non-egress client: the WWW-Authenticate resource_metadata pointer and authorization_servers[0] are server-controlled URLs, fetched with the plain 15s client (not the egress-enforced transport) at login. Laptop-side + operator-initiated + bounded by maxMetadataBytes/timeout, so low severity, and the runtime/refresh path is egress-enforced — but it's a discovery-time SSRF surface worth one line acknowledging (a malicious MCP url can steer the login-time fetch at an internal metadata endpoint).
  • No re-registration trigger on client revocation/expiry: DCR is "never re-minted," so if the AS revokes the client or client_secret_expires_at passes, the persisted record silently goes stale and the operator must DeleteRecord + re-login. Acceptable for v1; worth a one-line doc note on the recovery path.

Verdict

The feature is well-built and the discovery/DCR layering is correct. Finding 1 is the one I'd gate merge on — a plaintext-persisted confidential secret is a real exposure, and since the token path doesn't use it yet, not persisting it is a strictly-safe, zero-cost fix. Findings 2–3 are a docs reframe and a cheap fail-closed test; the incremental minors are follow-ups.

Comment thread forge-core/mcp/oauth_discovery.go Outdated
TokenURL: firstNonEmpty(cfg.TokenURL, meta.TokenEndpoint),
RegistrationURL: meta.RegistrationEndpoint,
ClientID: clientID,
ClientSecret: clientSecret,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security): this persists a DCR confidential-client secret that can land in plaintext — and it's unused today.

Chain: registerClient returns client_secret from the RFC 7591 response → stored here → oauth.SaveRecord (line 149) → SaveRecord falls to saveRecordPlaintext (store.go:174) when no encrypted backend is configured. So on a host without a keyring/passphrase, the AS-issued secret is written in clear to mcp_reg_<name>.json. Encrypted-preferred/plaintext-fallback is fine for a client_id; not for a secret.

The PR body notes the token path stays public-client PKCE and doesn't send client_secret yet — so this secret is persisted and unused. Simplest fix: don't persist ClientSecret at all right now (omit it from the record before SaveRecord); design secret handling with encryption required when the confidential token path actually lands. Alternative: fail closed on the plaintext path when a secret is present. Under admission-time DCR the platform holds these secrets, so Forge shouldn't be their store of record at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 534b6a2 — went with the strictly-safe option: stop persisting the secret entirely (dropped ClientSecret from the registration record and OAuthServerConfig) and fail closed when an AS issues a confidential client. Since we register token_endpoint_auth_method: none, a returned secret means we can't authenticate that client on the public-PKCE token path anyway — so failing closed with a pointer to explicit config is both safe and honest. No secret reaches the store on any path. Test: TestResolveOAuthConfig_ConfidentialClientFailsClosed asserts the error and that nothing was persisted (no host learned back).

return mergeRegistration(cfg, reg), nil
}

if !allowDiscovery {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assert this fail-closed (finding 3): it's currently emergent, not pinned.

This !allowDiscovery guard is what makes a partially-materialized config fail closed on the refresh path — step-1 needs all three fields, step-2 finds no record in a fresh pod, and refresh (BearerTokenallowDiscovery=false) lands here with ErrNoToken, never minting. That's the correct drift-prevention behavior, but nothing tests it.

Worth a cheap test: on the refresh path, a partial platform config (one env var empty) returns ErrNoToken and never mints a divergent client. Worth a comment on the asymmetry too: the login path (allowDiscovery=true) with the same partial config falls through to DCR and mints its own client — so "partial ⇒ fail-closed" is specifically a refresh-path property. Pinning it turns an emergent property into an asserted invariant, which is the whole point for the managed-drift scenario.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinned in 534b6a2TestResolveOAuthConfig_RefreshPartialConfigFailsClosed: partial config (client_id present, endpoints empty) on the refresh path (allowDiscovery=false) returns ErrNoToken and mints nothing (0 DCR calls). Added the asymmetry comment at the gate too: the login path with the same partial config falls through to DCR, so "partial ⇒ fail-closed" is specifically a refresh-path property.

Comment thread docs/mcp/configuration.md Outdated

**Precedence & rules:**

- **Explicit config always wins.** Set `client_id`/`authorize_url`/`token_url`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small reframe (finding 2): "Explicit config always wins" frames explicit as the override for servers that don't advertise metadata / don't support DCR — but that's now incomplete. Explicit config is also the managed path: the normal case when a platform materializes client_id/endpoints from a registry entry. Suggest reframing as: discovery is the standalone default; explicit config is both the static override and the platform-materialized path. Otherwise a reader assumes explicit config is only for the exception case, when under admission-DCR it's the common one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reframed in 534b6a2: "Discovery is the standalone default; explicit config always wins" — now called out as both the static override AND the platform-materialized path (the common case when a control plane materializes config from a registry entry), not just the exception.

… fail-closed, multi-AS

Finding 1 (security, merge-gating): a DCR confidential-client secret was
persisted (and, without a passphrase, in the plaintext-fallback store)
yet never used — the token path is public-client PKCE. Stop persisting it
entirely: drop `ClientSecret` from the registration record and from
OAuthServerConfig, and **fail closed** when an AS issues a confidential
client (we register `token_endpoint_auth_method: none`, so a secret means
we can't authenticate it anyway) — pointing the operator at explicit
config. No secret ever reaches the store.

Finding 3 (test + comment): pin the refresh-path fail-closed invariant.
A partially-materialized config (client_id present, endpoints empty) on
the refresh path (allowDiscovery=false) now has a test asserting it
returns ErrNoToken and mints nothing — and a comment documents the
asymmetry (the login path with the same partial config would fall
through to DCR; "partial ⇒ fail-closed" is a refresh-path property).

Finding 2 (docs): reframe explicit config as the standalone default's
override AND the platform-materialized path (the common case under
managed provisioning), not only the exception.

Minors:
- Multi-AS: RFC 9728 advertises a list; discovery now tries every
  authorization_servers entry (was [0] only) so a dead primary still
  completes.
- SSRF note: comment that the login-time discovery fetch of
  server-controlled URLs rides the plain client (laptop-side; the
  runtime/refresh path is egress-enforced).
- Recovery: `Logout` now also deletes the registration record, so
  `forge mcp logout` + `login` is the re-discover/re-register path after
  a client is revoked; documented in configuration.md.

Tests: confidential-client fail-closed (nothing persisted),
refresh-partial-config fail-closed (no mint). Docs updated.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Addressed all three findings + the worthwhile minors in `534b6a2`:

  • Finding 1 (security, gating) — done, the strict-safe way. Not persisting the DCR secret at all: dropped ClientSecret from the registration record and OAuthServerConfig, and fail closed if an AS issues a confidential client (we register token_endpoint_auth_method: none, so a secret is unusable on the public-PKCE token path). No secret ever hits the store. This is also the right posture under admission-time DCR — Forge is never the store of record for a platform-minted secret.
  • Finding 2 (docs) — reframed. Explicit config is now documented as both the static override and the platform-materialized path.
  • Finding 3 (test) — pinned. New test asserts the refresh path fails closed on a partial config and never mints; comment documents the login-vs-refresh asymmetry at the gate.

Minors:

  • Multi-AS: discovery now iterates the full authorization_servers list (was [0] only) — a dead primary no longer blocks completion.
  • SSRF surface: one-line comment acknowledging the login-time fetch of server-controlled URLs rides the plain client (laptop-side; the runtime/refresh path is egress-enforced).
  • Revocation recovery: Logout now also deletes the registration record, so forge mcp logout <name> + login is the documented re-discover/re-register path when the AS revokes the client.

CI re-running.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 534b6a2 — all three findings resolved, finding 1 fixed beyond the ask. CI green.

Finding 1 (plaintext confidential secret) — fixed with the strictest option, plus an improvement.

  • ClientSecret dropped from both the persisted oauthRegistration record and OAuthServerConfig — the secret can never reach the store (plaintext or otherwise) or linger in in-memory config.
  • A DCR response that issues a confidential client now fails closed at resolve time with an actionable error ("Forge supports only public PKCE clients … configure client_id/authorize_url/token_url explicitly") instead of half-registering and failing later at token exchange with a confusing message.
  • registerClient still reads client_secret — necessary and correct, since you must detect the confidential issuance to refuse it — but never stores it.
  • TestResolveOAuthConfig_ConfidentialClientFailsClosed pins it with a test server whose /register returns a secret, asserting resolve fails and persists nothing.

That's better than the minimum: it removes the exposure AND turns "AS issued a confidential client" from a silent downstream auth failure into a clear config error.

Finding 3 (assert partial-materialization fail-closed) — pinned exactly as specified. TestResolveOAuthConfig_RefreshPartialConfigFailsClosed drives partial config (client_id present, endpoints empty) on the refresh path and asserts both halves: the error routes to forge mcp login (fails closed) AND regCalls == 0 (mints nothing). The code comment documents the login-vs-refresh asymmetry too. Emergent property → asserted invariant.

Finding 2 (docs reframe) — done verbatim. "Discovery is the standalone default; explicit config always wins," with explicit called out as "the platform-materialized path — the normal case when a control plane materializes these fields from a registry entry."

Still open (my incremental minors — all follow-up severity, untouched)

  • authorization_servers[0]-only with no fallback to [1] on a dead primary;
  • the server-controlled discovery redirect riding the non-egress client at login (bounded SSRF surface; runtime/refresh path is egress-enforced);
  • no re-registration trigger on client revocation / client_secret_expires_at.

These were framed as follow-ups, so leaving them is fine — worth a tracking issue rather than blocking.

Verdict

Merge-ready. The merge-gating finding (plaintext secret) is resolved cleanly — the secret now touches nothing — and both security-relevant behaviors (confidential-refuse, partial-refresh-fail-closed) are pinned by tests. Good outcome: the drift scenario is now an asserted invariant rather than an accident of allowDiscovery.

`mcp login` and `mcp logout` apply the MCP token-store-path override
(forge.yaml `mcp.token_store_path` > env `MCP_TOKEN_STORE_PATH`) via
oauth.SetCredentialsDir before touching the store, but `mcp test` did
not — so `test` always read the default ~/.forge/credentials and
reported "no stored token" for any server logged in under a custom
store path. Apply the same override in mcp test.

Found while manually testing the #316 discovery flow with an isolated
token store.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Manual end-to-end test results ✅

Tested the built binary two ways — an offline mock and real Linear. Both green.

Variant A — offline mock (deterministic RFC-by-RFC)

A local mock MCP server implementing RFC 9728 / 8414 / 7591 + authorize/token. With auth: {type: oauth} and no client_id/endpoints:

  • forge mcp login mock → mock logged each step in order: 401 WWW-Authenticate[RFC 9728] protected-resource → [RFC 8414] auth-server → [RFC 7591] minted client_id → authorize → token.
  • Persisted mcp_reg_mock.json carried the discovered endpoints + minted client_id and no client_secret (finding-1 fix confirmed).
  • Reuse: a 2nd login hit neither the well-knowns nor /register — resolved straight from the persisted registration (explicit → persisted → discover precedence).
  • forge run --no-auth under mode: deny-all emitted egress_allowed domain=127.0.0.1 — the discovered AS host, learned into the allowlist from the registration record though it's nowhere in forge.yaml. (The mock isn't a real MCP server, so it then degraded on initialize 401 and, being required:false, warned + continued — expected.)
  • Fail-closed paths also exercised: no-registration_endpoint and confidential-client (/register returning a client_secret) both fail closed with clear messages and persist nothing.

Variant B — real Linear (https://mcp.linear.app/mcp)

Verified live that Linear advertises the full chain (RFC 9728 + 8414 + a registration_endpoint), then:

  • forge mcp login linear — zero config, real 3-legged consent, DCR-minted client, token persisted.
  • forge mcp test linear:
    connecting to linear (https://mcp.linear.app/mcp)...
      initialize: ok
      discovered 32 tool(s); 32 allowed by forge.yaml filter
    
  • forge run --no-auth — full green, no degraded loop:
    egress_allowed domain=mcp.linear.app mode=allowlist   # discovered host, strict allowlist
    mcp server initialized server=linear
    mcp_server_started tool_count=32
    mcp manager started oauth_used:true tools:32
    
  • A real agent task drove a real tool: LLM chose linear__list_projectsmcp_tool_result ok:true → returned live workspace data → session_end state=completed. So the discovery/DCR-provisioned credential works through an actual invocation, not just connect.

Incidental fix found while testing

mcp test didn't apply the token-store-path override that mcp login/logout do, so it read the default ~/.forge/credentials and reported "no stored token" for a server logged in under a custom store path. Fixed in 63f0f06 (this PR).

Coverage: discovery + DCR + secretless persistence + registration reuse + runtime token resolution + egress learn-back + fail-closed paths — verified on both a controlled mock and a real production server.

@initializ-mk initializ-mk merged commit c7650ef into main Jul 16, 2026
10 checks passed
initializ-mk added a commit that referenced this pull request Jul 16, 2026
… fail-closed, multi-AS

Finding 1 (security, merge-gating): a DCR confidential-client secret was
persisted (and, without a passphrase, in the plaintext-fallback store)
yet never used — the token path is public-client PKCE. Stop persisting it
entirely: drop `ClientSecret` from the registration record and from
OAuthServerConfig, and **fail closed** when an AS issues a confidential
client (we register `token_endpoint_auth_method: none`, so a secret means
we can't authenticate it anyway) — pointing the operator at explicit
config. No secret ever reaches the store.

Finding 3 (test + comment): pin the refresh-path fail-closed invariant.
A partially-materialized config (client_id present, endpoints empty) on
the refresh path (allowDiscovery=false) now has a test asserting it
returns ErrNoToken and mints nothing — and a comment documents the
asymmetry (the login path with the same partial config would fall
through to DCR; "partial ⇒ fail-closed" is a refresh-path property).

Finding 2 (docs): reframe explicit config as the standalone default's
override AND the platform-materialized path (the common case under
managed provisioning), not only the exception.

Minors:
- Multi-AS: RFC 9728 advertises a list; discovery now tries every
  authorization_servers entry (was [0] only) so a dead primary still
  completes.
- SSRF note: comment that the login-time discovery fetch of
  server-controlled URLs rides the plain client (laptop-side; the
  runtime/refresh path is egress-enforced).
- Recovery: `Logout` now also deletes the registration record, so
  `forge mcp logout` + `login` is the re-discover/re-register path after
  a client is revoked; documented in configuration.md.

Tests: confidential-client fail-closed (nothing persisted),
refresh-partial-config fail-closed (no mint). Docs updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP OAuth Phase 1.5: automated discovery (RFC 9728/8414) + dynamic client registration (RFC 7591)

1 participant