laravel/mcp: Prefer Client ID Metadata Documents over dynamic registration
Contributed by pushpak1300
Part of the mcp v1.0.1 release
This pull request adds support for Client ID Metadata Documents (CIMD) to the client-side OAuth flow, in line with MCP 2026-07-28, which deprecates Dynamic Client Registration (DCR). When the authorization server advertises CIMD support and your app publishes a metadata document, that document's URL becomes your client_id — no registration request, no throwaway client secret. Every OAuth client registered through Mcp::oAuthRoutesFor() now also publishes a public metadata document route, and PKCE validation is tightened in a way that is technically a breaking change. If you already use withOAuth() together with oAuthRoutesFor(), the upgrade is essentially free; you mainly need to verify APP_URL and audit the servers you connect to.
Why DCR was costing you
On v0.9.6, connecting without a configured client_id meant a fresh Dynamic Client Registration on every redirect() call: a POST to the server's registration_endpoint, a brand new client row, and a secret discarded immediately. Connect three times and you are three unrelated clients on the authorization server, each cluttering its database and each shown on the consent screen under a generated name. CIMD flips that around: your client_id is a stable HTTPS URL — for example https://acme.com/mcp/oauth/github/client-metadata.json — and the server fetches the JSON behind it to learn who you are, where you redirect, and that you are a public client with no secret.
The new resolution order
redirect() now resolves identity in this priority order:
clientIdpassed towithOAuth()— used as-is, unchanged.- The server advertises
client_id_metadata_document_supported: trueand this app publishes a usable document URL — the URL becomes theclient_id. New. - The server exposes a
registration_endpoint— DCR, unchanged. - None of the above — an
OAuthExceptionwhose message now names all three options.
The document URL is only used when it is genuinely usable: HTTPS, a non-empty path, no query string or fragment, and a publicly resolvable host. localhost, .test/.local-style domains, and private or loopback IP addresses silently fall back to DCR, so local development against a DCR-enabled sandbox keeps working exactly as before.
A new public route, published for you
If you already register OAuth routes, a third route appears alongside connect and callback:
Mcp::oAuthRoutesFor('github', function (string $client, TokenSet $token) {
Auth::user()->update(['github_mcp_token' => $token->accessToken]);
return redirect('/dashboard');
});
GET /mcp/oauth/github/client-metadata.json now serves:
{
"client_name": "Acme MCP Client",
"client_uri": "https://acme.com",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_id": "https://acme.com/mcp/oauth/github/client-metadata.json",
"redirect_uris": ["https://acme.com/mcp/oauth/github/callback"],
"token_endpoint_auth_method": "none"
}
client_name and client_uri default to APP_NAME (plus " MCP Client") and APP_URL. The route carries no middleware and sets Cache-Control: max-age=3600, public, because the authorization server has to fetch it anonymously and is encouraged to cache it.
One subtlety worth calling out: client_id, redirect_uris and client_uri are built from config('app.url'), not from the incoming request's host. The document stays consistent no matter which hostname serves the request, which matters because the spec requires client_id to match the document URL exactly. Set APP_URL to your canonical public URL before upgrading.
Customizing without weakening
oAuthRoutesFor() accepts two new optional arguments: clientMetadataUri to move the route, and clientMetadata to merge in fields:
Mcp::oAuthRoutesFor('github', $handler, clientMetadata: [
'client_name' => 'Acme Dashboard',
'logo_uri' => 'https://acme.com/logo.png',
]);
Custom redirect_uris are appended to the published callback and de-duplicated, so you can declare extra legacy callback URLs without losing the built-in one. The core fields (client_id, the callback redirect_uri, token_endpoint_auth_method) are spread last and cannot be overridden, and client_secret, client_secret_expires_at and registration_access_token are stripped entirely — the CIMD spec forbids shared secrets in these documents.
Upgrade notes: PKCE is now strictly enforced
The breaking change is unrelated to CIMD but shipped with it after the audit: redirect() now refuses to start the flow when the server's metadata omits code_challenge_methods_supported entirely. Previously only servers advertising the field without S256 were rejected, so omission slipped through with no PKCE guarantee. The MCP spec says clients MUST refuse in this case. This matters more now because CIMD clients are public — no secret — so PKCE is the only thing binding the authorization code to you. If a provider omits the field, the fix is on their side; there is no client-side opt-out.
Takeaways
- CIMD is now preferred over DCR whenever the server advertises it and your document URL is valid; the fallback chain to DCR and exceptions is unchanged otherwise.
- A new public, middleware-free route
/mcp/oauth/{client}/client-metadata.jsonis published automatically byoAuthRoutesFor()— whitelist it in any route caching or WAF rules, and passclientMetadataUriif you need a custom path. - The document is derived from
APP_NAME/APP_URL; verifyAPP_URLpoints at your canonical public origin. - Clients registered without
oAuthRoutesFor()(no metadata route) behave exactly as before and still use DCR. - BC break: servers whose metadata omits
code_challenge_methods_supportednow throw anOAuthException; they must advertiseS256. }