Files
tinker_tickets/mcp/server.php
T
jaredandClaude Opus 5.5 65deedc295
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
Add MCP identity mapping and read tools (#111, phase 4)
MCP requests now run as the signed-in Tinker Tickets user, under exactly
the same rules as the web login, and expose the first two tools:
search_tickets and get_ticket.

Identity:
- IdentityMiddleware maps the validated token to a user with the same
  checks as AuthMiddleware: the admin/employee group rule, now extracted
  into helpers/AccessPolicy.php so both entry points share one copy, then
  UserModel::syncUserFromAuthelia(), which creates/updates the row and
  derives is_admin from groups.
- Claims are read from the validated token's server-side PSR-7 request
  attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware
  is deliberately not used: it array_merges into client-writable _meta,
  so only the keys the validator happens to set are overwritten and a
  client could inject others.

Scopes (ToolScopeMiddleware), enforced before dispatch:
- lifecycle messages need only a valid token; write tools (listed in
  ToolCatalog, the single registry) need tickets:write; everything else
  needs tickets:read, which tickets:write implies.
- Denials are the spec's step-up challenge: 403 +
  WWW-Authenticate: Bearer error="insufficient_scope", scope=...,
  resource_metadata=...

Tools (read-only, annotated readOnlyHint):
- search_tickets: text/status/priority/category/assignee ("me",
  "unassigned", or a username), paginated, via TicketModel::getAllTickets
  with the user's visibility filter. Defaults to every non-Closed status.
- get_ticket: details + comments, gated by canUserAccessTicket. A missing
  ticket and a non-visible one return the same "not found".

Verified locally against a real MariaDB fixture (public, confidential,
internal+group, and closed tickets across two users), driving the real
pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT
validation stubbed: 20/20 checks pass, including visibility parity per
user, confidential tickets hidden from non-owners, the group check
rejecting a user without admin/employee, a missing preferred_username
rejected, 403 insufficient_scope for a token without tickets:*, and
write implying read. Also exercised the stateless 2026-07-28 era (no
session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which
returns the same visibility-filtered results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:58:30 -04:00

155 lines
6.4 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';
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';
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;
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
use TinkerTickets\Mcp\ToolCatalog;
$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',
])),
);
$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();
$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),
// 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()
),
],
);
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);