Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
nyholm's ServerRequestCreator adds Host both from the URI it builds and
from the request headers, so under PHP-FPM getHeaderLine('Host') returns
"beta.t.lotusguild.org, beta.t.lotusguild.org". The SDK's DNS-rebinding
check compares that joined string against the allowlist and refused every
request with 403 "Invalid Host header", even for the correct hostname.
It only passed locally because PHP's built-in server exposes headers
differently.
Collapse Host to the single value the client sent before the middleware
runs. The rebinding check still sees the client's real Host, so foreign
hosts and direct-by-IP access stay refused.
Verified on the beta host by running the patched entrypoint against
beta's real config and vendor/: the correct host gets the Protected
Resource Metadata, and a foreign host still gets 403.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
136 lines
5.5 KiB
PHP
136 lines
5.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* MCP endpoint (Streamable HTTP), OAuth-protected by Authelia. See issue #111.
|
|
*
|
|
* Serves /mcp and the RFC 9728 Protected Resource Metadata paths (nginx routes
|
|
* all of them here). This is the ONLY file allowed to load vendor/autoload.php.
|
|
*
|
|
* Identity comes exclusively from the validated access token. Never read
|
|
* Remote-User / Remote-* headers or $_SESSION for identity here: this location
|
|
* is exempt from Authelia forward-auth at the proxy, so those headers are
|
|
* client-controlled on this path.
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
|
RateLimitMiddleware::apply('api', false);
|
|
|
|
require_once dirname(__DIR__) . '/config/config.php';
|
|
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
|
use Mcp\Server;
|
|
use Mcp\Server\Session\FileSessionStore;
|
|
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\OAuthRequestMetaMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
|
|
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
|
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
|
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
|
|
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
|
use Nyholm\Psr7Server\ServerRequestCreator;
|
|
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
|
use Symfony\Component\Cache\Psr16Cache;
|
|
|
|
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
|
|
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
|
|
if (empty($resourceUrl) || empty($issuer)) {
|
|
http_response_code(503);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => '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);
|