Expose OAuth challenges on authenticated MCP routes
Contributed by Gujiassh
Part of the mcp v1.0.1 release
Pull request #322 fixes a problem that quietly broke OAuth discovery for protected MCP servers: when authentication middleware rejected a request with a 401, the WWW-Authenticate challenge header was never attached, leaving spec-compliant MCP clients with no way to find out how to authenticate. The service provider now registers the AddWwwAuthenticateHeader middleware on the HTTP kernel's global stack so it wraps authentication, and the middleware itself gained a guard so it only decorates responses from MCP routes that actually opt in. If you're upgrading from v0.9.6 there is nothing to configure, but the behavior of your protected MCP endpoints changes in a way your users' OAuth clients will notice.
What changed
Three things moved in the package internals.
First, McpServiceProvider::boot() now calls a new registerGlobalMiddleware() method. It waits for Laravel's HTTP kernel to resolve and pushes AddWwwAuthenticateHeader onto the kernel's middleware stack, which places it outside the priority-sorted route middleware pipeline — including authentication.
Second, because the middleware now runs on every request, it learned to be picky. Before decorating anything, it inspects the matched route and confirms the middleware class actually appears in app('router')->gatherRouteMiddleware($route). Requests that didn't hit an MCP route pass through completely untouched.
Third, a new feature suite, tests/Feature/Server/OAuthChallengeTest.php, pins down the behavior: challenges are emitted when authentication is priority-sorted ahead of the route middleware, when a custom middleware short-circuits with a 401, and when auth is applied through the auth:api alias. It also asserts the two negative cases — plain non-MCP routes and routes that explicitly exclude the middleware stay header-free.
Why it matters
The MCP authorization story builds on OAuth 2.0 Protected Resource Metadata (RFC 9728). When a client calls a protected MCP endpoint without valid credentials, the server is expected to answer with a challenge like:
WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource/protected-mcp"
That resource_metadata URL is how a client discovers your authorization server, supported scopes, and token endpoints. Without it, a bare 401 is a dead end.
The bug was an ordering problem. Laravel sorts route middleware by its priority list, and Authenticate sits near the top. When auth middleware short-circuits a request with a 401, the response never flows back through middleware nested inside it — which is exactly where AddWwwAuthenticateHeader used to live. Moving the middleware to the global stack means it always sees the final response, while the new route check keeps its effect scoped to MCP routes.
What protected routes return now
Given a typical protected server:
Mcp::web('protected-mcp', ExampleServer::class)
->middleware('auth:sanctum');
On v0.9.6, an unauthenticated POST /protected-mcp returned just 401 Unauthorized. After this change, the same request returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://your-app.test/.well-known/oauth-protected-resource/protected-mcp"
One caveat: the header is only added when the package's OAuth protected-resource routes are registered — the middleware checks for the mcp.oauth.protected-resource.nested route name (as set up through the registrar's oauthRoutes()). If you protect MCP routes with OAuth but never registered those metadata routes, you'll still get plain 401s.
Upgrade impact
- No manual migration steps. The service provider handles the global registration for you; updating the package is all that's required.
- The middleware now runs on every request, but it exits immediately unless the response is a
401on a route that includes it, so the per-request overhead is a couple of comparisons. - Custom auth middleware gets decorated too. The regression tests cover a middleware that simply returns
response()->json(['error' => 'unauthorized'], 401)— as long as the route is an MCP route with this middleware, the challenge is attached. You're not limited to Laravel's built-inAuthenticate. - Non-MCP routes are untouched. A regular authenticated route still returns a header-free 401, which the suite asserts explicitly.
- Opt-out is still available per route, and it now works correctly despite the global registration:
use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader;
Mcp::web('opted-out-mcp', ExampleServer::class)
->withoutMiddleware(AddWwwAuthenticateHeader::class);
The reason opt-out works is subtle: the global middleware emulates the route pipeline by calling gatherRouteMiddleware(), which resolves middleware aliases and honors withoutMiddleware() exclusions. That's precisely what the alias test and the opt-out test lock in.
Takeaways
- Updating from v0.9.6 registers
AddWwwAuthenticateHeaderglobally via the service provider — no config changes needed. - Unauthenticated requests to OAuth-protected MCP routes now carry an RFC 9728
WWW-Authenticatechallenge, even when auth middleware short-circuits the request. - The challenge only appears when the OAuth protected-resource routes are registered and the route includes the middleware; use
withoutMiddleware(AddWwwAuthenticateHeader::class)to opt out. - Custom 401-returning middleware on MCP routes is supported; non-MCP routes are unaffected.