'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']); exit; } $psr17 = new Psr17Factory(); $request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); // ServerRequestCreator adds Host both from the URI and from the request // headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which // the DNS-rebinding check below then rejects. Collapse to the single value the // client actually sent. $clientHost = $request->getHeader('Host')[0] ?? ''; if ($clientHost !== '') { $request = $request->withHeader('Host', $clientHost); } // TLS terminates at the reverse proxy, so PHP sees plain http and a Host header // the client controls. The SDK derives the resource_metadata URL in its 401 // challenge from the request URI, so pin scheme/host/port to the configured // canonical URL instead of anything the request claims. preserveHost keeps // the client's real Host header for the DNS-rebinding check below; without // it withUri() would overwrite Host and make that check a no-op. $canonical = parse_url($resourceUrl); $request = $request->withUri( $request->getUri() ->withScheme($canonical['scheme']) ->withHost($canonical['host']) ->withPort($canonical['port'] ?? null), true ); // Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to // Authelia. Outside the webroot on purpose. $cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache')); $validator = new JwtTokenValidator( issuer: $issuer, audience: $resourceUrl, jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache), // Authelia puts scopes in an `scp` array, not the standard `scope` string // (verified in #111 phase 1). With the default, every scope check fails. scopeClaim: 'scp', ); $resourcePath = $canonical['path'] ?? ''; $metadata = new ProtectedResourceMetadata( authorizationServers: [$issuer], scopesSupported: ['tickets:read', 'tickets:write'], resource: $resourceUrl, resourceName: 'Tinker Tickets', // RFC 9728 path-suffixed form first (used in the WWW-Authenticate // challenge), plus the root form some clients probe. metadataPaths: array_values(array_unique([ '/.well-known/oauth-protected-resource' . $resourcePath, '/.well-known/oauth-protected-resource', ])), ); $server = Server::builder() ->setServerInfo('Tinker Tickets', '1.0.0') // Handshake-era clients (pre-2026-07-28) still use protocol sessions. ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')) ->build(); $transport = new StreamableHttpTransport( $request, middleware: [ // CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin // browser calls are refused). Host allowlist: only the canonical // hostname, which also refuses direct-by-IP access. new CorsMiddleware(), new DnsRebindingProtectionMiddleware([$canonical['host']]), new ProtectedResourceMetadataMiddleware($metadata), new AuthorizationMiddleware($validator, $metadata), new OAuthRequestMetaMiddleware(), ], ); try { $response = $server->run($transport); } catch (\Throwable $e) { error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage()); $response = $psr17->createResponse(500) ->withHeader('Content-Type', 'application/json') ->withBody($psr17->createStream(json_encode([ 'jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32603, 'message' => 'Internal error'], ]))); } (new SapiEmitter())->emit($response);