Files
tinker_tickets/mcp/server.php
T
jaredandClaude Opus 5.5 5631731a28
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
Add OAuth-protected MCP endpoint scaffolding (#111, phase 2)
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
2026-09-24 18:40:11 -04:00

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);