Here's a small thing that cost a disproportionate amount of head-scratching this week: a remote MCP connector kept failing to discover my OAuth server, even though the OAuth server was right there and working. The fix turned out to be one route and a 308 redirect — but the why behind it is a nice little lesson in how discovery specs drift apart in practice. So let me write it up.
This is in cleaniquecoders/laravel-mcp-kit, a public package, so all the code below is the real thing.
The setup: who's knocking, and what they expect
When you expose an MCP server over streamable HTTP and want remote clients (like claude.ai) to authenticate, you turn on OAuth. laravel/mcp gives you a one-liner for the discovery documents:
Mcp::oauthRoutes();
That registers the two documents the OAuth 2.1 world expects:
-
/.well-known/oauth-authorization-server— RFC 8414, authorization-server metadata. -
/.well-known/oauth-protected-resource— RFC 9728, protected-resource metadata.
A header-less connector hits those, learns where your authorization_endpoint, token_endpoint, and registration_endpoint live, self-registers via Dynamic Client Registration (RFC 7591), and away it goes. Clean.
Except some clients — and laravel/mcp's own client, in certain paths — don't probe the OAuth metadata. They probe /.well-known/openid-configuration instead. That's the OpenID Connect discovery document. Different spec, same neighbourhood. And oauthRoutes() doesn't register it, because OIDC is a superset of OAuth that the package isn't claiming to implement.
So you get this maddening situation: your OAuth server is correct and complete, but the client is knocking on a door you never built, getting a 404, and giving up.
The cheap fix vs. the right fix
The lazy fix is a reverse-proxy redirect — tell nginx to rewrite /.well-known/openid-configuration to the authorization-server doc. It works, but now your auth discovery depends on infra config that lives outside the app, isn't tested, and silently breaks the moment someone deploys to a host that doesn't have that rule. That's exactly the kind of "works on my machine" landmine I try to keep out of a package.
The right fix is to make the app itself answer the probe. Since the OIDC document these clients actually want overlaps almost entirely with the authorization-server metadata, I just alias one to the other:
if ($oauth) {
Mcp::oauthRoutes();
// oauthRoutes() registers the two OAuth discovery documents but not
// OpenID Connect discovery. Some connectors (and laravel/mcp's own
// client) still probe /.well-known/openid-configuration; alias it to
// the authorization-server metadata so hosts don't need a reverse-proxy
// redirect. 308 preserves the request for clients that follow it.
if (config('mcp-kit.web.oauth.openid_configuration', true)) {
Route::get(
'/.well-known/openid-configuration',
fn () => redirect()->route('mcp.oauth.authorization-server', [], 308)
)->name('mcp-kit.openid-configuration');
}
}
Two design decisions worth pausing on.
Why 308 and not 302. A 302 invites a client to switch a POST into a GET on the redirect (historically that's exactly what 302 implementations did). A 308 Permanent Redirect is the strict one: it tells the client "go here instead, and keep your method and body exactly as they were." For a discovery probe it's GET-on-GET either way, but 308 is the honest semantic — the resource genuinely lives at the other URL, permanently, and I don't want any client quietly mangling the request. Use the redirect code that says what you actually mean.
Why it's behind a config flag. The alias only makes sense when OAuth is on, so it's nested inside the if ($oauth) block — no point aliasing to an authorization-server route that isn't registered. And it's also guarded by config('mcp-kit.web.oauth.openid_configuration', true), defaulting to on, so a host that genuinely implements its own OIDC discovery can switch the alias off and not collide. Default to the behaviour that makes the common case work; leave an escape hatch for the host that knows better.
Lock it down with a test
The whole point of doing this in-app instead of in nginx is that I can test it. Two Pest tests: one asserts the redirect itself, one follows the redirect and asserts the metadata that comes back actually has the shape a connector needs.
it('aliases openid-configuration to the authorization-server discovery', function () {
$this->get('/.well-known/openid-configuration')
->assertStatus(308)
->assertRedirect(route('mcp.oauth.authorization-server'));
});
it('serves the same metadata once the openid-configuration alias is followed', function () {
$this->followingRedirects()
->get('/.well-known/openid-configuration')
->assertOk()
->assertJsonStructure([
'issuer',
'authorization_endpoint',
'token_endpoint',
'registration_endpoint',
'code_challenge_methods_supported',
'grant_types_supported',
]);
});
The first test pins the mechanism (the 308 to the right named route). The second pins the contract — followingRedirects() walks the alias all the way through, and the assertJsonStructure checks that a real connector following this path lands on a document with the keys it needs to self-register: where to authorize, where to get a token, where to register, and crucially code_challenge_methods_supported (PKCE) and grant_types_supported. If laravel/mcp ever changes the metadata shape under me, that second test goes red and I find out at CI, not when claude.ai can't connect.
That split — one test for the wiring, one test for the contract — is a habit I keep coming back to. The mechanism test stops me from breaking the route. The contract test stops a dependency from breaking the consumer.
The takeaway
Discovery specs that "should" be interchangeable rarely are in practice. OAuth 2.1 and OpenID Connect share a /.well-known/ neighbourhood and a lot of vocabulary, but clients pick whichever door they were built to knock on. When you're the server, the friendliest thing you can do is answer both doors and point them at the same room — in the app, with a redirect code that means what it says, behind a flag, and pinned with a test. No reverse-proxy magic, no host-specific config, nothing that breaks on the next deploy.
What's next: the same treatment for /.well-known/oauth-protected-resource edge cases that a couple of stricter clients probe with query parameters. Same idea, slightly more fiddly.