Use case — MCP authorization server
The MCP authorization specification recommends that a client identify itself with an HTTPS URL instead of registering first. The URL is the client_id, and it points at a JSON metadata document the client publishes (OAuth Client ID Metadata Document). The OP fetches the document, validates it, and treats the client as a temporary client for as long as the cached copy lives. Nothing is stored.
op.WithClientIDMetadataDocuments turns this on for every endpoint (authorization, PAR, token, introspection, revocation). It is off by default, and without it the OP behaves exactly as before.
Use it when MCP clients, or other software that cannot be known in advance, need to sign in. Skip it when every client is registered by you: a static client or dynamic registration is easier to reason about.
Experimental — tracks an IETF draft
Client ID Metadata Document is an IETF draft, not a published RFC, and the MCP specification cites an earlier revision than the one implemented here. op.WithClientIDMetadataDocuments and its related symbols are marked Experimental: in godoc: a draft update may make a wire-incompatible change.
Wiring
import "github.com/libraz/go-oidc-provider/op"
op.New(
op.WithIssuer("https://op.example.com"),
/* required options */
op.WithProtectedResources(op.ProtectedResource{
Resource: "https://op.example.com/mcp",
ScopesSupported: []string{"mcp.read", "mcp.write"},
}),
op.WithClientIDMetadataDocuments(op.ClientIDMetadataDocuments{
Allow: func(ctx context.Context, clientID string) bool {
return strings.HasPrefix(clientID, "https://clients.example.org/")
},
}),
)Discovery then carries client_id_metadata_document_supported: true, and token_endpoint_auth_methods_supported includes none. op.New refuses the configuration when a profile removes none, and refuses it together with a FAPI profile, because a self-declared client cannot meet FAPI's client requirements.
Allow is optional. When set, it is called with the client_id URL on every resolution, cache hits included, before anything is fetched; returning false makes the client unknown. A narrower allow-list takes effect at once. When it is nil, every URL that passes the URL rules is admitted.
The supported topology: co-hosted resource
An MCP client sends the MCP server's address as the RFC 8707 resource. The supported setup is the OP publishing that resource through op.WithProtectedResources, as above. A client identified by a metadata document may then request any resource you declared there; narrowing it down is left to the user's consent. An MCP server hosted elsewhere that publishes its own resource metadata is not covered by this release.
What a client may be
| Aspect | Accepted |
|---|---|
token_endpoint_auth_method | none (the default) or private_key_jwt, within the methods your profile allows |
grant_types | authorization_code and refresh_token |
response_types | code |
| PKCE | required, including for private_key_jwt |
redirect_uris | required, validated like dynamic registration, except that a localhost loopback URI is accepted |
client_secret* members | rejected |
| Unknown members | ignored |
A document that breaks any rule is rejected as a whole. With private_key_jwt, the document must carry jwks or jwks_uri, and jwks must hold public keys only. scope in the document is intersected with the scopes your OP publishes; a document without scope gets all of them.
A client registered under the same ID, by static configuration or dynamic registration, always wins and nothing is fetched. Clients that use client credentials, device, CIBA or token exchange cannot be URL-identified.
What the user sees
URL-identified clients never get the first-party consent skip, and listing such a URL in op.WithFirstPartyClients fails at construction. The consent screen shows the host of the client_id URL (with the port when one is given) and the host of the requested redirect_uri. When the redirect is a loopback address, it also shows a warning. The client's logo is never fetched or shown.
Custom consent screens receive the same values in interaction.ClientView as MetadataDocumentHost, RedirectHost and RedirectLoopback, and should show them too. See custom consent UI.
Fetch and cache limits
These are fixed and not configurable:
| Limit | Value |
|---|---|
| URL | https, a path other than /, no query, fragment, userinfo or dot segments, ASCII host, at most 255 bytes |
| Response | 200 only, application/json or application/*+json, no redirects |
| Body | 5 KiB |
| Timeout | 5 seconds |
| Network | private, loopback, link-local and metadata addresses refused when connecting |
| Cache | Cache-Control max-age clamped to 60 seconds to 24 hours; no-store counts as 60 seconds |
| Failures | an unusable document is remembered for 5 seconds; a full fetch gate is not remembered |
Concurrent requests for one URL share a single fetch, and fetches share the process-wide outbound gate with JAR and client JWKS retrieval. A failed fetch surfaces to the client as an unknown client, and the reason (status, network, too_large, content_type, json or validation) goes to the audit stream as cimd.fetch_failed. When the gate is full the request fails with server_error.
If a document changes, the new content applies from the next fetch. Existing grants and refresh tokens are not revoked (why).
Local development
op.WithAllowPrivateNetwork(op.OutboundClientIDMetadata) admits private and loopback targets and the http scheme for client_id URLs, so a test can serve a document from httptest, and so can a deployment whose clients live on a private network. op.New logs a warning because Client ID Metadata Documents lets an unauthenticated caller choose the URL (SSRF exposure); restrict ClientIDMetadataDocuments.Allow when you enable it. The document's jwks_uri is a separate fetch governed by op.OutboundJWKS.
Read next
- Protected resource metadata — declaring the resource an MCP client asks for.
- Dynamic client registration — the registration path for clients you do know.
- Design judgments — the fetch envelope, the consent display, and the limits of this feature.