diff --git a/helpers/AccessPolicy.php b/helpers/AccessPolicy.php new file mode 100644 index 0000000..071000b --- /dev/null +++ b/helpers/AccessPolicy.php @@ -0,0 +1,31 @@ + preg_match('/^[a-z0-9_\-]+$/', $g) + ); + + return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS)); + } +} diff --git a/mcp/server.php b/mcp/server.php index e9e2dca..1c65b5d 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -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() + ), ], ); diff --git a/mcp/src/Auth/IdentityMiddleware.php b/mcp/src/Auth/IdentityMiddleware.php new file mode 100644 index 0000000..c130cdb --- /dev/null +++ b/mcp/src/Auth/IdentityMiddleware.php @@ -0,0 +1,78 @@ +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], + ]))); + } +} diff --git a/mcp/src/Auth/McpIdentity.php b/mcp/src/Auth/McpIdentity.php new file mode 100644 index 0000000..fbc30ed --- /dev/null +++ b/mcp/src/Auth/McpIdentity.php @@ -0,0 +1,59 @@ + */ + private static array $scopes = []; + + /** + * @param array $user + * @param list $scopes + */ + public static function set(array $user, array $scopes): void + { + self::$user = $user; + self::$scopes = $scopes; + } + + /** + * @return array + */ + 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); + } +} diff --git a/mcp/src/Auth/ToolScopeMiddleware.php b/mcp/src/Auth/ToolScopeMiddleware.php new file mode 100644 index 0000000..395a984 --- /dev/null +++ b/mcp/src/Auth/ToolScopeMiddleware.php @@ -0,0 +1,91 @@ +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 + ) + ); + } +} diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php new file mode 100644 index 0000000..b09825d --- /dev/null +++ b/mcp/src/ToolCatalog.php @@ -0,0 +1,33 @@ +addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly) + ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly); + } +} diff --git a/mcp/src/Tools/TicketReadTools.php b/mcp/src/Tools/TicketReadTools.php new file mode 100644 index 0000000..2a9f5cf --- /dev/null +++ b/mcp/src/Tools/TicketReadTools.php @@ -0,0 +1,183 @@ + + */ + 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 + */ + 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 $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 $t + * + * @return array + */ + 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']), + ]; + } +} diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php index 27686fb..9d68ab8 100644 --- a/middleware/AuthMiddleware.php +++ b/middleware/AuthMiddleware.php @@ -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 ?? '')); } /**