diff --git a/mcp/server.php b/mcp/server.php index 1c65b5d..78254c4 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -23,6 +23,11 @@ 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'; +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'; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use Mcp\Server; diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php index b09825d..41d57e0 100644 --- a/mcp/src/ToolCatalog.php +++ b/mcp/src/ToolCatalog.php @@ -5,6 +5,7 @@ namespace TinkerTickets\Mcp; use Mcp\Schema\ToolAnnotations; use Mcp\Server\Builder; use TinkerTickets\Mcp\Tools\TicketReadTools; +use TinkerTickets\Mcp\Tools\TicketWriteTools; /** * The single list of MCP tools: registration, plus which ones need the @@ -14,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketReadTools; final class ToolCatalog { /** Tool names that mutate data and require tickets:write. */ - private const WRITE_TOOLS = []; + private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket']; public static function isWriteTool(string $name): bool { @@ -24,10 +25,18 @@ final class ToolCatalog public static function register(Builder $builder, \mysqli $conn): Builder { $read = new TicketReadTools($conn); + $write = new TicketWriteTools($conn); $readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false); + // Writes change tickets (and notify people) but never delete anything. + $additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false); + $update = new ToolAnnotations(readOnlyHint: false, 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); + ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly) + ->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive) + ->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive) + ->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update) + ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update); } } diff --git a/mcp/src/Tools/TicketWriteTools.php b/mcp/src/Tools/TicketWriteTools.php new file mode 100644 index 0000000..2b28012 --- /dev/null +++ b/mcp/src/Tools/TicketWriteTools.php @@ -0,0 +1,216 @@ + + */ + public function createTicket( + string $title, + string $description, + int $priority = 4, + string $category = 'General', + string $type = 'Issue', + string $visibility = 'public', + ?string $visibility_groups = null, + ?string $assignee = null, + ): array { + $user = McpIdentity::user(); + + // The web form constrains these with dropdowns; an API caller can send + // anything, so validate them here before handing off. + if ($priority < 1 || $priority > 5) { + throw new ToolCallException('priority must be between 1 and 5'); + } + if (!in_array($visibility, self::VISIBILITIES, true)) { + throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES)); + } + + $assignedTo = null; + if ($assignee !== null && trim($assignee) !== '') { + $assignedTo = $this->resolveUserId(trim($assignee), $user); + } + + $result = \TicketCreationService::create($this->conn, $user, [ + 'title' => $title, + 'description' => $description, + 'priority' => (string)$priority, + 'category' => $category, + 'type' => $type, + 'status' => 'Open', + 'visibility' => $visibility, + 'visibility_groups' => $visibility_groups, + 'assigned_to' => $assignedTo, + ]); + if (!$result['success']) { + throw new ToolCallException($result['error']); + } + + return [ + 'ticket_id' => (string)$result['ticket_id'], + 'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']), + ]; + } + + /** + * Post a comment on a ticket as you. @username mentions notify that user. + * + * @param string $ticket_id The ticket ID. + * @param string $text The comment text. + * @param bool $markdown Render the comment as markdown. + * @param int|null $reply_to comment_id of the comment you are replying to, if any. + * + * @return array + */ + public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array + { + $user = McpIdentity::user(); + $result = \CommentService::addComment($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'comment_text' => $text, + 'markdown_enabled' => $markdown, + 'parent_comment_id' => $reply_to, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return [ + 'comment_id' => (int)$result['comment_id'], + 'ticket_id' => trim($ticket_id), + 'mentions' => $result['mentions'] ?? [], + ]; + } + + /** + * Change a ticket's status. Transitions follow the Workflow Designer rules; + * some transitions (e.g. closing) require a comment, which is posted as the reason. + * + * @param string $ticket_id The ticket ID. + * @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed". + * @param string|null $comment Reason for the change; required for transitions that need one. + * + * @return array + */ + public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array + { + $user = McpIdentity::user(); + $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed']; + if (!in_array($status, $validStatuses, true)) { + throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses)); + } + + $ticketId = trim($ticket_id); + if (!ctype_digit($ticketId)) { + throw new ToolCallException("Ticket {$ticketId} not found"); + } + + $controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user); + $data = ['status' => $status]; + if ($comment !== null && trim($comment) !== '') { + $data['comment'] = $comment; + $data['markdown_enabled'] = true; + } + $result = $controller->update($ticketId, $data); + if (empty($result['success'])) { + if (!empty($result['requires_comment'])) { + throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.'); + } + throw new ToolCallException($this->errorMessage($result, $ticketId)); + } + + // api/update_ticket.php invalidates the dashboard stats cache after a + // successful update (outside the controller); do the same here. + (new \StatsModel($this->conn))->invalidateCache(); + + return ['ticket_id' => $ticketId, 'status' => $result['status']]; + } + + /** + * Assign a ticket to someone, or unassign it. Only admins, the ticket's + * creator, or its current assignee may do this. + * + * @param string $ticket_id The ticket ID. + * @param string $assignee Username to assign to, "me", or "unassigned". + * + * @return array + */ + public function assignTicket(string $ticket_id, string $assignee): array + { + $user = McpIdentity::user(); + $target = trim($assignee); + $assignedTo = strtolower($target) === 'unassigned' || $target === '' + ? null + : $this->resolveUserId($target, $user); + + $result = \AssignmentService::assign($this->conn, $user, [ + 'ticket_id' => $ticket_id, + 'assigned_to' => $assignedTo, + ]); + if (empty($result['success'])) { + throw new ToolCallException($this->errorMessage($result, $ticket_id)); + } + + return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target]; + } + + /** + * @param array $user + */ + private function resolveUserId(string $username, array $user): int + { + if (strtolower($username) === 'me') { + return (int)$user['user_id']; + } + $match = (new \UserModel($this->conn))->getUserByUsername($username); + if (!$match) { + throw new ToolCallException("Unknown user: {$username}"); + } + return (int)$match['user_id']; + } + + /** + * Map a shared-service failure to a tool error. A ticket the user can't + * see reads as "not found" (like get_ticket), never "access denied", so a + * restricted ticket's existence isn't revealed. + * + * @param array $result + */ + private function errorMessage(array $result, string $ticketId): string + { + $error = (string)($result['error'] ?? 'Request failed'); + if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) { + return 'Ticket ' . trim($ticketId) . ' not found'; + } + return $error; + } +}