MCP: ticket links and similar-ticket search (#113)
Lint / PHP (phpcs PSR-12) (push) Successful in 48s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m15s
Lint / Deploy (push) Successful in 4s
Lint / PHP (phpcs PSR-12) (push) Successful in 48s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m15s
Lint / Deploy (push) Successful in 4s
- get_ticket now returns `links` (blocks / blocked_by / relates_to /
duplicates / duplicated_by, phrased from this ticket's side, limited to
linked tickets the user can see) and `blocked` (any open blocked_by).
- find_similar_tickets (tickets:read): the possible-duplicates finder, by
title or by an existing ticket (which is excluded from the results).
- link_tickets / unlink_tickets (tickets:write). Marking a duplicate only
records the link. unlink also finds a link stored from the other side
("B blocked_by A" for "A blocks B").
api/ticket_dependencies.php's list/add/remove logic moves to
services/DependencyService.php, used by both (same checks and messages).
DependencyModel's remove methods now return rows removed, so removing a
link that is already gone no longer writes a "deleted" audit row.
Also includes the port in ToolScopeMiddleware's resource_metadata URL
(matches the 401's; no effect on prod, which has no port).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
This commit is contained in:
@@ -15,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketWriteTools;
|
||||
final class ToolCatalog
|
||||
{
|
||||
/** Tool names that mutate data and require tickets:write. */
|
||||
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket'];
|
||||
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket', 'link_tickets', 'unlink_tickets'];
|
||||
|
||||
public static function isWriteTool(string $name): bool
|
||||
{
|
||||
@@ -27,16 +27,20 @@ final class ToolCatalog
|
||||
$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.
|
||||
// Writes change tickets (and notify people); only unlink_tickets removes anything.
|
||||
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
|
||||
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
|
||||
$remove = new ToolAnnotations(readOnlyHint: false, destructiveHint: true, 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, 'findSimilarTickets'], 'find_similar_tickets', 'Find similar tickets', 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);
|
||||
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update)
|
||||
->addTool([$write, 'linkTickets'], 'link_tickets', 'Link tickets', null, $additive)
|
||||
->addTool([$write, 'unlinkTickets'], 'unlink_tickets', 'Unlink tickets', null, $remove);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ final class TicketReadTools
|
||||
/**
|
||||
* Get one ticket's full details and its comments.
|
||||
*
|
||||
* `links` lists the ticket's relationships from this ticket's point of view:
|
||||
* blocks, blocked_by, relates_to, duplicates (this ticket duplicates the
|
||||
* other) and duplicated_by (the other ticket duplicates this one). `blocked`
|
||||
* is true while any blocked_by ticket is not Closed.
|
||||
*
|
||||
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
|
||||
* @param bool $include_comments Include the ticket's comments, newest first.
|
||||
*
|
||||
@@ -121,6 +126,12 @@ final class TicketReadTools
|
||||
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
|
||||
];
|
||||
|
||||
$details['links'] = $this->links($ticketId, $user);
|
||||
$details['blocked'] = (bool)array_filter(
|
||||
$details['links'],
|
||||
fn(array $l) => $l['relation'] === 'blocked_by' && $l['status'] !== 'Closed'
|
||||
);
|
||||
|
||||
if ($include_comments) {
|
||||
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
|
||||
$details['comment_count'] = count($comments);
|
||||
@@ -139,6 +150,92 @@ final class TicketReadTools
|
||||
return $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open tickets with titles similar to the given text, or to an
|
||||
* existing ticket's title — the same check as the "possible duplicates"
|
||||
* list on the ticket page. Title matching only; use search_tickets for
|
||||
* keyword search over descriptions. To record a duplicate, call
|
||||
* link_tickets with relation "duplicates".
|
||||
*
|
||||
* @param string|null $title Title text to compare against (at least 5 characters).
|
||||
* @param string|null $ticket_id Or: an existing ticket whose title to compare; it is left out of the results.
|
||||
* @param int $limit Maximum matches, 1-10.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function findSimilarTickets(?string $title = null, ?string $ticket_id = null, int $limit = 5): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$excludeId = null;
|
||||
|
||||
if ($ticket_id !== null && trim($ticket_id) !== '') {
|
||||
$ticketModel = new \TicketModel($this->conn);
|
||||
$excludeId = trim($ticket_id);
|
||||
$ticket = preg_match('/^\d+$/', $excludeId) ? $ticketModel->getTicketById($excludeId) : null;
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
|
||||
throw new ToolCallException("Ticket {$excludeId} not found");
|
||||
}
|
||||
$title = (string)$ticket['title'];
|
||||
}
|
||||
if ($title === null || strlen(trim($title)) < 5) {
|
||||
throw new ToolCallException('Provide a title of at least 5 characters, or a ticket_id');
|
||||
}
|
||||
|
||||
$limit = min(10, max(1, $limit));
|
||||
// One extra so excluding the ticket itself still leaves $limit matches.
|
||||
$matches = \SimilarTicketService::find($this->conn, $user, $title, $limit + 1);
|
||||
$matches = array_values(array_filter($matches, fn(array $m) => (string)$m['ticket_id'] !== $excludeId));
|
||||
|
||||
return [
|
||||
'title' => trim($title),
|
||||
'matches' => array_map(fn(array $m) => [
|
||||
'ticket_id' => (string)$m['ticket_id'],
|
||||
'title' => $m['title'],
|
||||
'status' => $m['status'],
|
||||
'priority' => (int)$m['priority'],
|
||||
'similarity' => (int)$m['similarity'],
|
||||
'url' => \UrlHelper::ticketUrl((string)$m['ticket_id']),
|
||||
], array_slice($matches, 0, $limit)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket's links in both directions, each phrased from this ticket's
|
||||
* side. Linked tickets the user can't see are left out by the service.
|
||||
*
|
||||
* @param array<string, mixed> $user
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function links(string $ticketId, array $user): array
|
||||
{
|
||||
$result = \DependencyService::list($this->conn, $user, $ticketId);
|
||||
if (!$result['success']) {
|
||||
throw new ToolCallException($result['error']);
|
||||
}
|
||||
|
||||
// Stored as "<dependent> <type> <this ticket>"; restate from this side.
|
||||
$reverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to', 'duplicates' => 'duplicated_by'];
|
||||
$rows = [];
|
||||
foreach ($result['dependencies'] as $type => $deps) {
|
||||
foreach ($deps as $d) {
|
||||
$rows[] = [$type, (string)$d['depends_on_id'], $d];
|
||||
}
|
||||
}
|
||||
foreach ($result['dependents'] as $d) {
|
||||
$rows[] = [$reverse[$d['dependency_type']] ?? $d['dependency_type'], (string)$d['ticket_id'], $d];
|
||||
}
|
||||
|
||||
return array_map(fn(array $r) => [
|
||||
'relation' => $r[0],
|
||||
'ticket_id' => $r[1],
|
||||
'title' => $r[2]['title'] ?? null,
|
||||
'status' => $r[2]['status'] ?? null,
|
||||
'priority' => isset($r[2]['priority']) ? (int)$r[2]['priority'] : null,
|
||||
'url' => \UrlHelper::ticketUrl($r[1]),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ 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
|
||||
* AssignmentService, DependencyService), 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).
|
||||
*/
|
||||
@@ -183,6 +183,109 @@ final class TicketWriteTools
|
||||
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two tickets, as shown on the ticket page's Dependencies tab. Read it
|
||||
* as "ticket_id <relation> other_ticket_id":
|
||||
* - blocks: ticket_id must be done before other_ticket_id.
|
||||
* - blocked_by: ticket_id is waiting on other_ticket_id.
|
||||
* - relates_to: the tickets are related.
|
||||
* - duplicates: ticket_id is a duplicate of other_ticket_id (the original).
|
||||
* This only records the link; close the duplicate separately with
|
||||
* update_status if that's wanted.
|
||||
* Circular blocking chains and links that already exist (from either side)
|
||||
* are rejected.
|
||||
*
|
||||
* @param string $ticket_id The ticket the relation is stated from.
|
||||
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
|
||||
* @param string $other_ticket_id The other ticket.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function linkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
|
||||
|
||||
$result = \DependencyService::add($this->conn, $user, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $otherId,
|
||||
'dependency_type' => $relation,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
if (($result['error'] ?? '') === 'Target ticket not found') {
|
||||
throw new ToolCallException("Ticket {$otherId} not found");
|
||||
}
|
||||
throw new ToolCallException($this->errorMessage($result, $ticketId));
|
||||
}
|
||||
|
||||
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link between two tickets. Takes the same arguments as
|
||||
* link_tickets; a link recorded from the other ticket's side ("B blocked_by A"
|
||||
* for "A blocks B") is found and removed too.
|
||||
*
|
||||
* @param string $ticket_id The ticket the relation is stated from.
|
||||
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
|
||||
* @param string $other_ticket_id The other ticket.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function unlinkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
|
||||
|
||||
// The same relationship can be stored from either ticket. duplicates
|
||||
// has no inverse type, so it is only ever stored one way.
|
||||
$inverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
|
||||
$attempts = [[$ticketId, $otherId, $relation]];
|
||||
if (isset($inverse[$relation])) {
|
||||
$attempts[] = [$otherId, $ticketId, $inverse[$relation]];
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
foreach ($attempts as [$from, $to, $type]) {
|
||||
$result = \DependencyService::remove($this->conn, $user, [
|
||||
'ticket_id' => $from,
|
||||
'depends_on_id' => $to,
|
||||
'dependency_type' => $type,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
throw new ToolCallException($this->errorMessage($result, $from));
|
||||
}
|
||||
$removed += (int)$result['removed'];
|
||||
}
|
||||
if ($removed === 0) {
|
||||
throw new ToolCallException("No {$relation} link between {$ticketId} and {$otherId}");
|
||||
}
|
||||
|
||||
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId, 'removed' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string}
|
||||
*/
|
||||
private function linkArgs(string $ticketId, string $relation, string $otherId): array
|
||||
{
|
||||
$ticketId = trim($ticketId);
|
||||
$otherId = trim($otherId);
|
||||
$relation = strtolower(trim($relation));
|
||||
if (!in_array($relation, \DependencyService::TYPES, true)) {
|
||||
throw new ToolCallException('relation must be one of: ' . implode(', ', \DependencyService::TYPES));
|
||||
}
|
||||
foreach ([$ticketId, $otherId] as $id) {
|
||||
if (!ctype_digit($id)) {
|
||||
throw new ToolCallException("Ticket {$id} not found");
|
||||
}
|
||||
}
|
||||
if ($ticketId === $otherId) {
|
||||
throw new ToolCallException('A ticket cannot be linked to itself');
|
||||
}
|
||||
return [$ticketId, $otherId, $relation];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user