First step of the remote MCP server: mcp/server.php serves /mcp over
Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly
0.8.1 since it breaks BC in nearly every minor release), with Authelia as
the OAuth authorization server. No tools yet: this phase only stands up
authentication, RFC 9728 Protected Resource Metadata, and routing.
- Composer is introduced for the MCP endpoint ONLY: nothing else loads
vendor/autoload.php, so a failed composer install at deploy time can
only take /mcp down. vendor/ is gitignored and excluded from phpcs;
composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4.
- Tokens are validated against Authelia's JWKS (cached) for signature,
issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod
and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts
scopes in an array claim of that name, not the standard 'scope'.
- The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the
SDK sees it: TLS ends at the proxy, so PHP sees http and a
client-controlled Host, and the SDK builds the 401 challenge's
resource_metadata URL from that. preserveHost keeps the real Host
header for the DNS-rebinding check, which only allows the canonical
hostname (so direct-by-IP access is refused too).
- Identity will come only from the token; this entrypoint never reads
Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth
at the proxy and those headers are client-controlled there.
Verified locally with PHP's built-in server: unauthenticated POST gets
401 + WWW-Authenticate with the https resource_metadata URL and scopes;
metadata served at both /.well-known/oauth-protected-resource/mcp and the
root form; malformed token -> 401 invalid_token; foreign Host and
direct-IP Host -> 403; a forged Remote-User header without a token is
still 401.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X