Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m17s
Lint / Deploy (push) Successful in 7s
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
127 lines
5.1 KiB
PHP
127 lines
5.1 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();
|
|
|
|
// 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);
|