2026-09-24 18:40:11 -04:00
|
|
|
<?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';
|
2026-09-24 18:58:30 -04:00
|
|
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
2026-09-24 19:13:41 -04:00
|
|
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/services/CommentService.php';
|
|
|
|
|
require_once dirname(__DIR__) . '/services/AssignmentService.php';
|
2026-09-24 18:40:11 -04:00
|
|
|
|
|
|
|
|
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\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;
|
2026-09-24 18:58:30 -04:00
|
|
|
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
|
|
|
|
|
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
|
|
|
|
|
use TinkerTickets\Mcp\ToolCatalog;
|
2026-09-24 18:40:11 -04:00
|
|
|
|
|
|
|
|
$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();
|
|
|
|
|
|
2026-09-24 18:46:52 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:40:11 -04:00
|
|
|
// 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',
|
|
|
|
|
])),
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-24 18:58:30 -04:00
|
|
|
$conn = Database::getConnection();
|
|
|
|
|
|
|
|
|
|
$server = ToolCatalog::register(
|
|
|
|
|
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')),
|
|
|
|
|
$conn
|
|
|
|
|
)->build();
|
2026-09-24 18:40:11 -04:00
|
|
|
|
|
|
|
|
$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),
|
2026-09-24 18:58:30 -04:00
|
|
|
// Identity comes from the validated token's server-side request
|
|
|
|
|
// attributes, never from client-writable JSON-RPC `_meta` (so the
|
|
|
|
|
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
|
|
|
|
|
new IdentityMiddleware($conn, $psr17, $psr17),
|
|
|
|
|
new ToolScopeMiddleware(
|
|
|
|
|
$psr17,
|
|
|
|
|
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
|
|
|
|
|
),
|
2026-09-24 18:40:11 -04:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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);
|