Agent OAuth fails in boring ways: missing PKCE, wrong APP_URL, a new dynamic client on every redirect, a token that has scopes but no tenant. Laravel MCP 1.0 makes two of those failures explicit.
As of the 1.0 upgrade guide and Laravel MCP auth docs:
- PKCE is required.
OAuthClient::redirect()throws if the authorization server metadata omitscode_challenge_methods_supported(and S256 is the expected method). - Client ID Metadata Documents are preferred over Dynamic Client Registration (DCR). Your
client_idcan be an HTTPS URL to a JSON document your app hosts.
This post is the multi-tenant SaaS reading of those rules: how to register clients, where tenant binding belongs, and the auth failures agents hit in production. Pair with stateless MCP servers, MCP auth and audit, and Laravel multi-tenancy.
PKCE is not optional anymore
Previously, some servers omitted code_challenge_methods_supported and clients proceeded anyway. Laravel MCP 1.0 rejects that. If you operate the authorization server (Passport / your IdP), publish:
"code_challenge_methods_supported": ["S256"]
in /.well-known/oauth-authorization-server.
If you are the MCP client connecting to a third party that will not advertise PKCE, you need another grant that fits (for example pre-issued credentials where appropriate). There is no “turn off PKCE” flag in redirect().
For public agents and desktop hosts, PKCE is how you survive without embedding a client secret. Treat missing PKCE metadata as a hard misconfiguration, not a soft warning.
Client ID Metadata Documents
MCP revision 2026-07-28 deprecates DCR in favor of metadata documents. Flow in Laravel:
Mcp::oAuthRoutesFor('github', $handler)registers connect, callback, andGET /mcp/oauth/{client}/client-metadata.json- When the auth server advertises
client_id_metadata_document_supported, Laravel can use that document URL asclient_id - The app is treated as a public client:
token_endpoint_auth_methodisnone, and$token->clientSecretisnull
Implications:
- Database columns for
client_secretmust be nullable - Refresh / token calls must accept a null secret
APP_URLmust be correct in production; the document is built from it, not from the incoming Host header- The metadata route is unauthenticated on purpose so the authorization server can fetch it
Custom path / extra fields:
Mcp::oAuthRoutesFor(
'github',
$handler,
clientMetadataUri: 'oauth/github/metadata.json',
clientMetadata: [
'client_name' => 'Acme Dashboard',
'logo_uri' => 'https://acme.com/logo.png',
],
);
This also fixed a nasty 0.x footgun: calling redirect() repeatedly could DCR a new client every time. Metadata documents stop that churn.

Multi-tenant: bind workspace at consent

Scopes alone do not name a tenant. A token with mcp:use (or your custom scopes) that is not bound to workspace acme-eu will happily call tools until your handler guesses wrong.
Pattern that holds up:
- Consent UI lets the human pick the workspace (or confirms the only one they can access).
- Persist
tenant_id/workspace_idon the authorization code or token record you issue. - On every tool call, resolve tenant from the token, not from a free-form argument the model invents.
- Optionally allow a tool argument to select among authorized tenants, never to invent a new one.
- Audit
user_id + tenant_id + tool + idempotency_key.
If you connect outbound as an MCP client to GitHub-like servers per tenant, store the TokenSet per user_id + tenant_id + mcp_client_name. Do not share one refresh across workspaces.
Resource indicators / audience checks (RFC 8707 style) matter when the same IdP protects multiple APIs. Validate that the access token was minted for your MCP resource, not for an unrelated API.
Common agent auth failures
| Failure | Symptom | Fix |
|---|---|---|
| No PKCE advertisement | OAuthException on redirect | Publish code_challenge_methods_supported: ["S256"] |
Wrong APP_URL | Metadata client_id / redirect mismatch | Fix env; redeploy metadata |
| DCR every connect | Orphan clients on IdP | Prefer metadata documents |
| Null secret surprise | Insert fails / refresh breaks | Nullable columns; auth method none |
| Scope without tenant | Cross-tenant tool calls | Bind tenant at consent; enforce in tools |
| Legacy tests | 400 / -32020 | Send MCP headers + _meta on 2026-07-28 |
Agents will narrate these as “the tool is broken.” Your job is to make the HTTP and OAuth errors structured enough that the model (or your skill) can tell the human to reconnect.
Checklist for SaaS MCP OAuth
- Advertise PKCE S256 on your auth server metadata.
- Prefer Client ID Metadata Documents; keep DCR only as fallback.
- Ensure metadata route is public; connect/callback stay behind
web(or your chosen) middleware. - Make
client_secretstorage nullable. - Bind tenant at consent; enforce on every tool.
- Store outbound MCP tokens per tenant.
- Log auth failures with correlation ids (stateless servers).
- Dogfood with Claude / Cursor / your bot against a second workspace to prove isolation.
Takeaways
- Laravel MCP 1.0 requires PKCE support in authorization server metadata before redirect.
- Client ID Metadata Documents replace habitual DCR;
client_idmay be a URL with null secret. - Multi-tenant safety is consent-time binding plus per-tool enforcement, not scope strings alone.
- Fix
APP_URL, nullable secrets, and header/_metatests before blaming the model. - Treat reconnect UX as part of the product; agents cannot heal a broken OAuth client id.
If an agent can call refund_order with a token from workspace A while the chat is about workspace B, where did tenant binding fail: consent, token storage, or the tool handler?