Add MCP write tools: create_ticket, add_comment, update_status, assign_ticket (#111, phase 5)
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Each tool is a thin adapter over the same code path the web UI uses (TicketCreationService, CommentService, ApiTicketController, AssignmentService), run as the signed-in user, so permissions, Workflow Designer rules, audit entries, notifications and stats-cache invalidation are identical to doing the same thing in the browser. - Registered in ToolCatalog and listed in its WRITE_TOOLS, so ToolScopeMiddleware requires tickets:write for them. - Annotated readOnlyHint=false / destructiveHint=false (nothing deletes). - Input the web form constrains with dropdowns (priority 1-5, visibility, status) is validated in the tools. Assignees are "me", a username or "unassigned". - A ticket the user can't see reads as "not found" (never "access denied"), consistent with get_ticket. - update_status turns requires_comment into an actionable error and invalidates the stats cache like api/update_ticket.php does. Verified locally through the real pipeline (only JWT validation stubbed) against MariaDB with seeded workflow transitions: 30/30 checks, including a read-only token getting 403 insufficient_scope on create_ticket with nothing written; create/comment/status/assign attributed and audit-logged as the user; @mentions; internal visibility needing groups and staying hidden from non-members; an invisible confidential ticket not found for comment/status; requires_comment enforced, and closing with a reason persisted in one transaction; transitions outside the workflow refused; the admin/creator/assignee rule for assigning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
@@ -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;
|
||||
|
||||
+11
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Tools;
|
||||
|
||||
use Mcp\Exception\ToolCallException;
|
||||
use TinkerTickets\Mcp\Auth\McpIdentity;
|
||||
|
||||
/**
|
||||
* Write tools. Each one is a thin adapter over the exact code path the web UI
|
||||
* uses (TicketCreationService, CommentService, ApiTicketController,
|
||||
* AssignmentService), run as the signed-in user, so permissions, workflow
|
||||
* rules, audit entries, notifications and stats-cache invalidation are
|
||||
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
|
||||
*/
|
||||
final class TicketWriteTools
|
||||
{
|
||||
private const VISIBILITIES = ['public', 'internal', 'confidential'];
|
||||
|
||||
public function __construct(private readonly \mysqli $conn)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ticket as you. It starts in the Open status.
|
||||
*
|
||||
* @param string $title Short summary of the issue.
|
||||
* @param string $description Full description (markdown supported).
|
||||
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
|
||||
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
|
||||
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
|
||||
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
|
||||
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
|
||||
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user