feat(KAN-72): complete MCP ticket editing and discovery
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-10-02 13:34:00 +02:00
parent ba12e65e81
commit 9ed8d0c6cc
12 changed files with 894 additions and 12 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_project_configuration')]
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
#[IsReadOnly]
class GetProjectConfiguration extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'project_id' => ['required', 'integer'],
'people_page' => ['sometimes', 'integer', 'min:1'],
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
$columnPositions = $columns->pluck('position', 'id');
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
)->values();
return Response::structured([
'data' => [
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
'permissions' => [
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
],
'board_columns' => $columns->toArray(),
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
'position' => $status->position, 'board_column_id' => $status->board_column_id,
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
])->all(),
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
],
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
];
}
}
+8 -5
View File
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('get_ticket')]
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly]
class GetTicket extends Tool
{
@@ -25,11 +25,14 @@ class GetTicket extends Tool
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'ticket_id' => ['required', 'integer'],
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
$ticket = isset($validated['ticket_id'])
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
@@ -43,8 +46,8 @@ class GetTicket extends Tool
{
return [
'ticket_id' => $schema->integer()
->description('Ticket ID.')
->required(),
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('list_projects')]
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
#[IsReadOnly]
class ListProjects extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
$user = $this->kanbanGitMcp->user($request);
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Ticket;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('search_tickets')]
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
#[IsReadOnly]
class SearchTickets extends Tool
{
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
public function handle(Request $request): Response|ResponseFactory
{
$input = $request->validate([
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
'query' => ['sometimes', 'string', 'max:255'],
'project_id' => ['sometimes', 'integer'],
'ticket_status_id' => ['sometimes', 'integer'],
'ticket_type_id' => ['sometimes', 'integer'],
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$user = $this->kanbanGitMcp->user($request);
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
if (Arr::exists($input, $filter)) {
$query->where($filter, $input[$filter]);
}
}
if (Arr::exists($input, 'query')) {
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
}
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
return Response::structured([
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
'id' => $ticket->id,
'ticket_key' => $ticket->ticket_key,
'title' => $ticket->title,
'project_id' => $ticket->project_id,
'ticket_status_id' => $ticket->ticket_status_id,
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
])->all(),
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
'project_id' => $schema->integer()->description('Visible project ID.'),
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\KanbanGitMcp;
use App\Mcp\Support\TicketUpdateInput;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Name('update_ticket')]
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
#[IsDestructive(false)]
class UpdateTicket extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly TicketUpdateInput $ticketUpdateInput,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
$user = $this->kanbanGitMcp->user($request);
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
'meta' => ['updated' => $updated],
]);
}
/** @return array<string, Type> */
public function schema(JsonSchema $schema): array
{
return [
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
'description' => $schema->string()->nullable()->description('Description; null clears it.'),
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
'acceptance_criteria' => $schema->array()->items($schema->object([
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid.'),
'field_values' => $schema->array()->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
];
}
}