Add MCP identity mapping and read tools (#111, phase 4)
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

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
This commit is contained in:
2026-09-24 18:58:30 -04:00
co-authored by Claude Opus 5.5
parent 13660b4398
commit 65deedc295
8 changed files with 503 additions and 22 deletions
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* Who may use Tinker Tickets at all. Shared by the web login
* (AuthMiddleware, from Authelia's Remote-Groups header) and the MCP endpoint
* (from the OAuth access token's groups claim), so both entry points enforce
* one rule instead of two copies that can drift apart.
*/
class AccessPolicy
{
/** Membership in any of these grants access. */
private const REQUIRED_GROUPS = ['admin', 'employee'];
/**
* @param string $groups Comma-separated group names (Remote-Groups format)
*/
public static function hasAppAccess(string $groups): bool
{
if ($groups === '') {
return false;
}
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
fn($g) => preg_match('/^[a-z0-9_\-]+$/', $g)
);
return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
}
}
+26 -7
View File
@@ -17,6 +17,12 @@ 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;
@@ -24,7 +30,6 @@ 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;
@@ -35,6 +40,9 @@ 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;
@@ -99,11 +107,15 @@ $metadata = new ProtectedResourceMetadata(
])),
);
$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();
$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,
@@ -115,7 +127,14 @@ $transport = new StreamableHttpTransport(
new DnsRebindingProtectionMiddleware([$canonical['host']]),
new ProtectedResourceMetadataMiddleware($metadata),
new AuthorizationMiddleware($validator, $metadata),
new OAuthRequestMetaMiddleware(),
// 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()
),
],
);
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Maps a validated access token to a Tinker Tickets user, applying exactly
* the same rules as the web login (AuthMiddleware): the shared
* AccessPolicy::hasAppAccess() group check, then
* UserModel::syncUserFromAuthelia() to create/update the user row and derive
* is_admin from groups.
*
* Must run after the SDK's AuthorizationMiddleware, which has already
* verified the token's signature, issuer, audience and expiry and attached
* its claims as `oauth.claims` / `oauth.scopes` request attributes.
*/
final class IdentityMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly \mysqli $conn,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$claims = $request->getAttribute('oauth.claims');
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if (!is_array($claims)) {
// Only reachable if the stack is misordered; never serve anonymously.
return $this->deny(500, 'Identity unavailable');
}
$username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
if ($username === '') {
return $this->deny(403, 'Access token has no preferred_username claim');
}
$groupsClaim = $claims['groups'] ?? [];
$groups = implode(',', array_filter(
is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
'is_string'
));
if (!\AccessPolicy::hasAppAccess($groups)) {
return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
}
$user = (new \UserModel($this->conn))->syncUserFromAuthelia(
$username,
is_string($claims['name'] ?? null) ? $claims['name'] : '',
is_string($claims['email'] ?? null) ? $claims['email'] : '',
$groups
);
McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
return $handler->handle($request);
}
private function deny(int $status, string $message): ResponseInterface
{
return $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32001, 'message' => $message],
])));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace TinkerTickets\Mcp\Auth;
/**
* The Tinker Tickets user an MCP request runs as, plus the token's scopes.
*
* Set once per HTTP request by IdentityMiddleware from the *validated* access
* token, which is held as server-side PSR-7 request attributes. It is
* deliberately NOT read from the JSON-RPC `_meta` field: clients can write
* to `_meta`, and the SDK's OAuthRequestMetaMiddleware only overwrites the
* keys the validator happens to set.
*
* Static state is request-scoped here: PHP-FPM serves one HTTP request per
* process lifecycle, and the whole MCP call (including SDK fibers) runs
* inside it.
*/
final class McpIdentity
{
public const SCOPE_READ = 'tickets:read';
public const SCOPE_WRITE = 'tickets:write';
private static ?array $user = null;
/** @var list<string> */
private static array $scopes = [];
/**
* @param array<string, mixed> $user
* @param list<string> $scopes
*/
public static function set(array $user, array $scopes): void
{
self::$user = $user;
self::$scopes = $scopes;
}
/**
* @return array<string, mixed>
*/
public static function user(): array
{
if (self::$user === null) {
// Unreachable if the middleware stack is intact; fail closed.
throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
}
return self::$user;
}
/** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
public static function canRead(array $scopes): bool
{
return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
}
public static function canWrite(array $scopes): bool
{
return in_array(self::SCOPE_WRITE, $scopes, true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TinkerTickets\Mcp\ToolCatalog;
/**
* Per-operation scope enforcement, before anything is dispatched.
*
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
* need only a valid token.
* - tools/call on a write tool needs tickets:write.
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
* tickets:write implies.
*
* Denials use the MCP spec's step-up challenge: HTTP 403 +
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
* the request needs in one go.
*/
final class ToolScopeMiddleware implements MiddlewareInterface
{
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $resourceMetadataUrl,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() !== 'POST') {
return $handler->handle($request);
}
$payload = json_decode((string)$request->getBody(), true);
$request->getBody()->rewind();
if (!is_array($payload)) {
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
}
$messages = array_is_list($payload) ? $payload : [$payload];
$needWrite = false;
$needRead = false;
foreach ($messages as $message) {
if (!is_array($message) || !is_string($message['method'] ?? null)) {
continue; // responses to server->client requests carry no method
}
$method = $message['method'];
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
continue;
}
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
$needWrite = true;
} else {
$needRead = true;
}
}
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if ($needWrite && !McpIdentity::canWrite($scopes)) {
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
}
if ($needRead && !McpIdentity::canRead($scopes)) {
return $this->insufficient(
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
'This operation requires the tickets:read scope.'
);
}
return $handler->handle($request);
}
private function insufficient(string $scope, string $description): ResponseInterface
{
return $this->responseFactory->createResponse(403)->withHeader(
'WWW-Authenticate',
sprintf(
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
$scope,
$this->resourceMetadataUrl,
$description
)
);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace TinkerTickets\Mcp;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server\Builder;
use TinkerTickets\Mcp\Tools\TicketReadTools;
/**
* The single list of MCP tools: registration, plus which ones need the
* tickets:write scope (read by ToolScopeMiddleware). Keeping both here means
* a new write tool can't be registered without also being scope-gated.
*/
final class ToolCatalog
{
/** Tool names that mutate data and require tickets:write. */
private const WRITE_TOOLS = [];
public static function isWriteTool(string $name): bool
{
return in_array($name, self::WRITE_TOOLS, true);
}
public static function register(Builder $builder, \mysqli $conn): Builder
{
$read = new TicketReadTools($conn);
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
return $builder
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Read-only tools. Everything goes through the same model methods and
* visibility checks the web UI uses, as the signed-in user, so MCP can never
* show more than that user could see in their browser.
*/
final class TicketReadTools
{
private const MAX_COMMENTS = 200;
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Search and list tickets you can see (same visibility rules as the web UI).
*
* @param string|null $query Free-text search over ticket titles and descriptions.
* @param string|null $status Comma-separated statuses, e.g. "Open,In Progress". Omit for every status except Closed; use "all" for every status.
* @param int|null $priority Exact priority: 1 (critical) to 5 (minimal).
* @param string|null $category Exact category name.
* @param string|null $assignee "me", "unassigned", or a username.
* @param int $page Page number, starting at 1.
* @param int $limit Tickets per page, 1-50.
*
* @return array<string, mixed>
*/
public function searchTickets(
?string $query = null,
?string $status = null,
?int $priority = null,
?string $category = null,
?string $assignee = null,
int $page = 1,
int $limit = 20,
): array {
$user = McpIdentity::user();
$allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if ($status === null || trim($status) === '') {
$statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
} elseif (strtolower(trim($status)) === 'all') {
$statusFilter = null;
} else {
$requested = array_map('trim', explode(',', $status));
$unknown = array_diff($requested, $allStatuses);
if ($unknown) {
throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
. '. Valid statuses: ' . implode(', ', $allStatuses));
}
$statusFilter = implode(',', $requested);
}
$filters = [];
if ($priority !== null) {
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
if ($assignee !== null && trim($assignee) !== '') {
$filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
}
$page = max(1, $page);
$limit = min(50, max(1, $limit));
$result = (new \TicketModel($this->conn))->getAllTickets(
$page,
$limit,
$statusFilter,
'updated_at',
'desc',
($category !== null && trim($category) !== '') ? trim($category) : null,
null,
($query !== null && trim($query) !== '') ? trim($query) : null,
$filters,
$user
);
return [
'tickets' => array_map([$this, 'summarize'], $result['tickets']),
'page' => $result['current_page'],
'pages' => $result['pages'],
'total' => $result['total'],
];
}
/**
* Get one ticket's full details and its comments.
*
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
* @param bool $include_comments Include the ticket's comments, newest first.
*
* @return array<string, mixed>
*/
public function getTicket(string $ticket_id, bool $include_comments = true): array
{
$user = McpIdentity::user();
$ticketModel = new \TicketModel($this->conn);
$ticketId = trim($ticket_id);
$ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
// Same "not found" for missing and not-visible, so a restricted
// ticket's existence isn't revealed.
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$details = $this->summarize($ticket) + [
'description' => $ticket['description'] ?? '',
'visibility_groups' => $ticket['visibility_groups'] ?? null,
'closed_at' => $ticket['closed_at'] ?? null,
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
];
if ($include_comments) {
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
$details['comment_count'] = count($comments);
$details['comments'] = array_map(fn(array $c) => [
'comment_id' => (int)$c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
'created_at' => $c['created_at'] ?? null,
'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
'text' => $c['comment_text'] ?? '',
], array_slice($comments, 0, self::MAX_COMMENTS));
if (count($comments) > self::MAX_COMMENTS) {
$details['comments_truncated'] = true;
}
}
return $details;
}
/**
* @param array<string, mixed> $user
*/
private function resolveAssigneeFilter(string $assignee, array $user): int|string
{
$lower = strtolower($assignee);
if ($lower === 'me') {
return (int)$user['user_id'];
}
if ($lower === 'unassigned') {
return 'unassigned';
}
$match = (new \UserModel($this->conn))->getUserByUsername($assignee);
if (!$match) {
throw new ToolCallException("Unknown user: {$assignee}");
}
return (int)$match['user_id'];
}
/**
* @param array<string, mixed> $t
*
* @return array<string, mixed>
*/
private function summarize(array $t): array
{
return [
'ticket_id' => (string)$t['ticket_id'],
'title' => $t['title'] ?? '',
'status' => $t['status'] ?? null,
'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
'category' => $t['category'] ?? null,
'type' => $t['type'] ?? null,
'visibility' => $t['visibility'] ?? 'public',
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
'created_at' => $t['created_at'] ?? null,
'updated_at' => $t['updated_at'] ?? null,
'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
];
}
}
+2 -15
View File
@@ -263,21 +263,8 @@ class AuthMiddleware
*/
private function checkGroupAccess($groups)
{
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
}
/**