From cd574ec26bea23de60833c710defa6f7c22514a6 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 20 Apr 2026 20:36:59 +0200 Subject: [PATCH] Add KanbanGit MCP v0.1 --- .../ListAssignedTicketsByVisibleProject.php | 8 +- .../ListTicketsByStatusByVisibleProject.php | 67 +++ app/Mcp/Servers/KanbanGitServer.php | 37 ++ app/Mcp/Support/KanbanGitMcp.php | 239 +++++++++++ app/Mcp/Tools/GetTicket.php | 50 +++ app/Mcp/Tools/ListMyAssignedTickets.php | 56 +++ app/Mcp/Tools/ListTicketsByStatus.php | 73 ++++ app/Mcp/Tools/MoveTicketStatus.php | 99 +++++ app/Mcp/Tools/UpdateTicketAssignee.php | 68 +++ app/Mcp/Tools/UpdateTicketDescription.php | 67 +++ config/scalar.php | 6 +- docs/mcp/kanbangit-mcp-v0.1.md | 125 ++++++ routes/ai.php | 8 + .../Api/PublicApiDocumentationTest.php | 3 +- tests/Feature/Mcp/KanbanGitMcpTest.php | 389 ++++++++++++++++++ 15 files changed, 1289 insertions(+), 6 deletions(-) create mode 100644 app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php create mode 100644 app/Mcp/Servers/KanbanGitServer.php create mode 100644 app/Mcp/Support/KanbanGitMcp.php create mode 100644 app/Mcp/Tools/GetTicket.php create mode 100644 app/Mcp/Tools/ListMyAssignedTickets.php create mode 100644 app/Mcp/Tools/ListTicketsByStatus.php create mode 100644 app/Mcp/Tools/MoveTicketStatus.php create mode 100644 app/Mcp/Tools/UpdateTicketAssignee.php create mode 100644 app/Mcp/Tools/UpdateTicketDescription.php create mode 100644 docs/mcp/kanbangit-mcp-v0.1.md create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/KanbanGitMcpTest.php diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php index f31200a..de2faf1 100644 --- a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -20,10 +20,16 @@ class ListAssignedTicketsByVisibleProject /** * @return array, tickets: array>}> */ - public function handle(User $viewer, User $assignee): array + public function handle(User $viewer, User $assignee, ?Project $project = null): array { /** @var EloquentCollection $visibleProjects */ $visibleProjects = $this->listVisibleProjects->handle($viewer); + if ($project !== null) { + $visibleProjects = $visibleProjects + ->where('id', $project->id) + ->values(); + } + $visibleProjectIds = $visibleProjects->pluck('id')->all(); if ($visibleProjectIds === []) { diff --git a/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php b/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php new file mode 100644 index 0000000..c8a0753 --- /dev/null +++ b/app/Actions/Tickets/ListTicketsByStatusByVisibleProject.php @@ -0,0 +1,67 @@ + $ticketStatuses + * @return array, tickets: array>}> + */ + public function handle(User $viewer, Collection $ticketStatuses, ?Project $project = null): array + { + /** @var EloquentCollection $visibleProjects */ + $visibleProjects = $this->listVisibleProjects->handle($viewer); + if ($project !== null) { + $visibleProjects = $visibleProjects + ->where('id', $project->id) + ->values(); + } + + $visibleProjectIds = $visibleProjects->pluck('id')->all(); + + if ($visibleProjectIds === [] || $ticketStatuses->isEmpty()) { + return []; + } + + $ticketStatusIds = $ticketStatuses->pluck('id')->all(); + + /** @var Collection> $ticketsByProject */ + $ticketsByProject = Ticket::query() + ->whereIn('project_id', $visibleProjectIds) + ->whereIn('ticket_status_id', $ticketStatusIds) + ->with([ + 'assignee', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->orderByDesc('sequence_number') + ->get() + ->groupBy('project_id'); + + return $visibleProjects + ->filter(fn (Project $visibleProject): bool => $ticketsByProject->has($visibleProject->id)) + ->map(fn (Project $visibleProject): array => [ + 'project' => ProjectResource::make($visibleProject)->resolve(), + 'tickets' => TicketResource::collection($ticketsByProject->get($visibleProject->id))->resolve(), + ]) + ->values() + ->all(); + } +} diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php new file mode 100644 index 0000000..fb5ab0f --- /dev/null +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -0,0 +1,37 @@ +user(); + + if (! $user instanceof User) { + throw new AuthenticationException('Authentication required.'); + } + + return $user; + } + + /** + * @throws AuthorizationException + */ + public function ensureCanRead(User $user, Project $project): void + { + if (! $this->permissionService->allows($user, Permission::ProjectRead, $project)) { + throw new AuthorizationException('You cannot view this project.'); + } + } + + /** + * @throws AuthorizationException + */ + public function ensureCanWrite(User $user, Project $project): void + { + if (! $this->permissionService->allows($user, Permission::ProjectWrite, $project)) { + throw new AuthorizationException('You cannot update tickets in this project.'); + } + } + + /** + * @throws AuthorizationException + */ + public function ticketForRead(User $user, int $ticketId): Ticket + { + $ticket = Ticket::query() + ->with('project') + ->find($ticketId); + + if ($ticket === null) { + throw ValidationException::withMessages([ + 'ticket_id' => 'Ticket was not found.', + ]); + } + + $this->ensureCanRead($user, $ticket->project); + + return $ticket; + } + + /** + * @throws AuthorizationException + */ + public function ticketForWrite(User $user, int $ticketId): Ticket + { + $ticket = Ticket::query() + ->with('project') + ->find($ticketId); + + if ($ticket === null) { + throw ValidationException::withMessages([ + 'ticket_id' => 'Ticket was not found.', + ]); + } + + $this->ensureCanWrite($user, $ticket->project); + + return $ticket; + } + + /** + * @throws AuthorizationException + */ + public function projectForRead(User $user, ?int $projectId): ?Project + { + if ($projectId === null) { + return null; + } + + $project = Project::query()->find($projectId); + + if ($project === null) { + throw ValidationException::withMessages([ + 'project_id' => 'Project was not found.', + ]); + } + + $this->ensureCanRead($user, $project); + + return $project; + } + + /** + * @param array $validated + * @return Collection|Response + */ + public function resolveTicketStatuses(User $user, array $validated, ?Project $project = null): Collection|Response + { + $projectIds = $project === null + ? $this->permissionService->viewableProjectIds($user) + : [$project->id]; + + if ($projectIds === []) { + return collect(); + } + + $ticketStatusId = $validated['ticket_status_id'] ?? null; + if ($ticketStatusId !== null) { + $ticketStatus = TicketStatus::query() + ->whereIn('project_id', $projectIds) + ->with('boardColumn') + ->find((int) $ticketStatusId); + + return $ticketStatus === null + ? Response::error('Ticket status was not found in a visible project.') + : collect([$ticketStatus]); + } + + $query = TicketStatus::query() + ->whereIn('project_id', $projectIds) + ->with(['boardColumn', 'project']) + ->orderBy('project_id') + ->orderBy('position'); + + if (($statusSlug = $validated['status_slug'] ?? null) !== null) { + $query->where('slug', $statusSlug); + } elseif (($statusName = $validated['status_name'] ?? null) !== null) { + $query->where('name', $statusName); + } else { + return Response::error('Provide ticket_status_id, status_slug, or status_name.'); + } + + $ticketStatuses = $query->get(); + + if ($ticketStatuses->count() <= 1) { + return $ticketStatuses; + } + + $matches = $ticketStatuses + ->map(fn (TicketStatus $ticketStatus): array => [ + 'id' => $ticketStatus->id, + 'name' => $ticketStatus->name, + 'project_id' => $ticketStatus->project_id, + 'project_name' => $ticketStatus->project?->name, + 'slug' => $ticketStatus->slug, + ]) + ->values() + ->all(); + + return Response::error(sprintf( + 'Status lookup is ambiguous. Provide ticket_status_id or project_id. Matches: %s', + json_encode($matches, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + )); + } + + /** + * @return array + */ + public function ticketPayload(Ticket $ticket): array + { + $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + return [ + 'project' => ProjectResource::make($ticket->project)->resolve(), + 'ticket' => TicketResource::make($ticket)->resolve(), + ]; + } + + /** + * @return array{ + * acceptance_criteria: string|null, + * assignee_user_id: int|null, + * author_user_id: int|null, + * blocked_comment: string|null, + * description: string|null, + * is_blocked: bool, + * parent_ticket_id: int|null, + * story_points: int|null, + * ticket_priority_id: int, + * ticket_type_id: int, + * title: string + * } + */ + public function updatePayload(Ticket $ticket): array + { + return [ + 'acceptance_criteria' => $ticket->acceptance_criteria, + 'assignee_user_id' => $ticket->assignee_user_id, + 'author_user_id' => $ticket->author_user_id, + 'blocked_comment' => $ticket->blocked_comment, + 'description' => $ticket->description, + 'is_blocked' => $ticket->is_blocked, + 'parent_ticket_id' => $ticket->parent_ticket_id, + 'story_points' => $ticket->story_points, + 'ticket_priority_id' => $ticket->ticket_priority_id, + 'ticket_type_id' => $ticket->ticket_type_id, + 'title' => $ticket->title, + ]; + } +} diff --git a/app/Mcp/Tools/GetTicket.php b/app/Mcp/Tools/GetTicket.php new file mode 100644 index 0000000..6949573 --- /dev/null +++ b/app/Mcp/Tools/GetTicket.php @@ -0,0 +1,50 @@ +validate([ + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket), + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListMyAssignedTickets.php b/app/Mcp/Tools/ListMyAssignedTickets.php new file mode 100644 index 0000000..c0f2d91 --- /dev/null +++ b/app/Mcp/Tools/ListMyAssignedTickets.php @@ -0,0 +1,56 @@ +validate([ + 'project_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForRead($user, $validated['project_id'] ?? null); + + return Response::structured([ + 'data' => $this->listAssignedTicketsByVisibleProject->handle($user, $user, $project), + 'meta' => [ + 'assignee' => UserResource::make($user)->resolve(), + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer() + ->nullable() + ->description('Optional project ID. When omitted, tickets from all visible projects are returned.'), + ]; + } +} diff --git a/app/Mcp/Tools/ListTicketsByStatus.php b/app/Mcp/Tools/ListTicketsByStatus.php new file mode 100644 index 0000000..581ca6c --- /dev/null +++ b/app/Mcp/Tools/ListTicketsByStatus.php @@ -0,0 +1,73 @@ +validate([ + 'project_id' => ['nullable', 'integer'], + 'status_name' => ['nullable', 'string'], + 'status_slug' => ['nullable', 'string'], + 'ticket_status_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForRead($user, $validated['project_id'] ?? null); + $ticketStatuses = $this->kanbanGitMcp->resolveTicketStatuses($user, $validated, $project); + + if ($ticketStatuses instanceof Response) { + return $ticketStatuses; + } + + return Response::structured([ + 'data' => $this->listTicketsByStatusByVisibleProject->handle($user, $ticketStatuses, $project), + 'meta' => [ + 'statuses' => TicketStatusResource::collection($ticketStatuses)->resolve(), + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer() + ->nullable() + ->description('Optional project ID. Use it to scope status_name or status_slug and avoid ambiguity.'), + 'status_name' => $schema->string() + ->nullable() + ->description('Exact ticket status name. Must match exactly and be unambiguous.'), + 'status_slug' => $schema->string() + ->nullable() + ->description('Ticket status slug. Must be unambiguous unless project_id is provided.'), + 'ticket_status_id' => $schema->integer() + ->nullable() + ->description('Stable ticket status ID. Preferred when available.'), + ]; + } +} diff --git a/app/Mcp/Tools/MoveTicketStatus.php b/app/Mcp/Tools/MoveTicketStatus.php new file mode 100644 index 0000000..b3b2d7e --- /dev/null +++ b/app/Mcp/Tools/MoveTicketStatus.php @@ -0,0 +1,99 @@ +validate([ + 'status_name' => ['nullable', 'string'], + 'status_slug' => ['nullable', 'string'], + 'ticket_id' => ['required', 'integer'], + 'ticket_status_id' => ['nullable', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $ticketStatuses = $this->kanbanGitMcp->resolveTicketStatuses($user, $validated, $ticket->project); + + if ($ticketStatuses instanceof Response) { + return $ticketStatuses; + } + + if ($ticketStatuses->isEmpty()) { + return Response::error('Ticket status was not found in this ticket project.'); + } + + $destinationStatus = $ticketStatuses->first(); + + if ($destinationStatus->project_id !== $ticket->project_id) { + return Response::error('Ticket status must belong to the same project as the ticket.'); + } + + $ticket->loadMissing('ticketStatus'); + + if ( + $ticket->ticket_status_id !== $destinationStatus->id + && ! ($ticket->ticketStatus?->allowedTargetStatuses()->whereKey($destinationStatus->id)->exists() ?? false) + ) { + return Response::error('Choose a status this ticket is allowed to move to.'); + } + + $result = $this->moveProjectTicket->handle($ticket->project, $ticket, $user, [ + 'ticket_status_id' => $destinationStatus->id, + ]); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'destination_label' => $result['destination_label'], + 'message' => $result['moved'] + ? "{$ticket->ticket_key} moved to {$result['destination_label']}." + : "{$ticket->ticket_key} already belongs to {$result['destination_label']}.", + 'moved' => $result['moved'], + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'status_name' => $schema->string() + ->nullable() + ->description('Exact target status name. Must resolve to one status in the ticket project.'), + 'status_slug' => $schema->string() + ->nullable() + ->description('Target status slug. Must resolve to one status in the ticket project.'), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + 'ticket_status_id' => $schema->integer() + ->nullable() + ->description('Stable target status ID. Preferred when available.'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateTicketAssignee.php b/app/Mcp/Tools/UpdateTicketAssignee.php new file mode 100644 index 0000000..2215ddc --- /dev/null +++ b/app/Mcp/Tools/UpdateTicketAssignee.php @@ -0,0 +1,68 @@ +validate([ + 'assignee_user_id' => ['present', 'nullable', 'integer', 'exists:users,id'], + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $payload = [ + ...$this->kanbanGitMcp->updatePayload($ticket), + 'assignee_user_id' => $validated['assignee_user_id'] ?? null, + ]; + + $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'message' => $wasUpdated + ? "{$ticket->ticket_key} assignee was updated." + : 'No ticket changes were detected.', + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'assignee_user_id' => $schema->integer() + ->nullable() + ->description('User ID to assign. Pass null to unassign.') + ->required(), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateTicketDescription.php b/app/Mcp/Tools/UpdateTicketDescription.php new file mode 100644 index 0000000..fc816bf --- /dev/null +++ b/app/Mcp/Tools/UpdateTicketDescription.php @@ -0,0 +1,67 @@ +validate([ + 'description' => ['nullable', 'string'], + 'ticket_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']); + $payload = [ + ...$this->kanbanGitMcp->updatePayload($ticket), + 'description' => $validated['description'] ?? null, + ]; + + $wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload); + + return Response::structured([ + 'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()), + 'meta' => [ + 'message' => $wasUpdated + ? "{$ticket->ticket_key} description was updated." + : 'No ticket changes were detected.', + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'description' => $schema->string() + ->nullable() + ->description('New ticket description. Pass null to clear it.'), + 'ticket_id' => $schema->integer() + ->description('Ticket ID.') + ->required(), + ]; + } +} diff --git a/config/scalar.php b/config/scalar.php index e750dee..d0a2db5 100644 --- a/config/scalar.php +++ b/config/scalar.php @@ -117,11 +117,9 @@ return [ */ 'hideClientButton' => true, - /** - * MCP will be introduced as a first-party KanbanGit feature later. - */ 'mcp' => [ - 'disabled' => true, + 'name' => 'KanbanGit MCP', + 'url' => '/mcp/kanbangit', ], /** diff --git a/docs/mcp/kanbangit-mcp-v0.1.md b/docs/mcp/kanbangit-mcp-v0.1.md new file mode 100644 index 0000000..ce70799 --- /dev/null +++ b/docs/mcp/kanbangit-mcp-v0.1.md @@ -0,0 +1,125 @@ +# KanbanGit MCP v0.1 + +KanbanGit exposes a first-party HTTP MCP server for AI tools that need to read and update tickets without interpreting the OpenAPI document directly. + +## Endpoint + +- URL: `/mcp/kanbangit` +- Transport: HTTP MCP +- Authentication: `Authorization: Bearer ` + +Application keys are created in Settings > Applications. MCP requests act with the full permissions of the key owner, so project visibility and write permissions match the web UI and JSON API. + +## Implemented Tools + +### `list_my_assigned_tickets` + +Lists tickets assigned to the authenticated application key owner, grouped by visible project. + +Inputs: + +- `project_id` optional integer. When omitted, all visible projects are included. + +Output: + +- `data`: project groups with `project` and `tickets` +- `meta.assignee`: authenticated user + +### `list_tickets_by_status` + +Lists tickets in a target status, grouped by visible project. + +Inputs: + +- `ticket_status_id` optional integer, preferred stable lookup +- `status_slug` optional string +- `status_name` optional string +- `project_id` optional integer to scope the lookup + +If `status_slug` or `status_name` matches more than one visible status, the tool returns an MCP error with matching status IDs so the client can retry with `ticket_status_id` or `project_id`. + +Output: + +- `data`: project groups with `project` and `tickets` +- `meta.statuses`: statuses used for the lookup + +### `get_ticket` + +Returns a visible ticket with its project, status, type, priority, assignee, author, field values, and worklog data. + +Inputs: + +- `ticket_id` required integer + +Output: + +- `data.project` +- `data.ticket` + +### `update_ticket_description` + +Updates only the ticket description and preserves all other ticket fields. + +Inputs: + +- `ticket_id` required integer +- `description` nullable string + +Requires project write permission. + +### `update_ticket_assignee` + +Assigns or unassigns a ticket and preserves all other ticket fields. + +Inputs: + +- `ticket_id` required integer +- `assignee_user_id` required nullable integer. Use `null` to unassign. + +Requires project write permission. + +### `move_ticket_status` + +Moves a ticket to an allowed target status. + +Inputs: + +- `ticket_id` required integer +- `ticket_status_id` optional integer, preferred stable lookup +- `status_slug` optional string +- `status_name` optional string + +The destination status must belong to the ticket project. When moving to a different status, the transition must be allowed by the ticket's current status. + +Output: + +- `data.project` +- `data.ticket` +- `meta.destination_label` +- `meta.message` +- `meta.moved` + +## OpenClaw Setup Notes + +Configure OpenClaw as an HTTP MCP client that points to your KanbanGit host plus `/mcp/kanbangit`. + +Send the application key as: + +```text +Authorization: Bearer +``` + +Use `list_my_assigned_tickets` for the dashboard-style "my work" view. Use `get_ticket` before updates when the bot needs current ticket context. Prefer IDs over names when moving statuses. + +## Noch fehlt + +- Ticket creation +- Branch tools +- Ticket links +- Comments and richer worklog tools +- Repository tools +- Project settings tools +- OAuth-based MCP auth +- Scoped or expiring MCP tokens +- Local stdio MCP server +- MCP prompts and resources diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 0000000..f950a2f --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,8 @@ +middleware(AuthenticateApiApplicationKey::class); diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 4886e24..90821ef 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -11,7 +11,8 @@ class PublicApiDocumentationTest extends TestCase $this->get('/api/docs') ->assertOk() ->assertSee('"hideClientButton":true', false) - ->assertSee('"mcp":{"disabled":true}', false); + ->assertSee('"hideTestRequestButton":true', false) + ->assertSee('"mcp":{"name":"KanbanGit MCP","url":"\/mcp\/kanbangit"}', false); } public function test_openapi_document_is_publicly_served(): void diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php new file mode 100644 index 0000000..75517d4 --- /dev/null +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -0,0 +1,389 @@ +postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ])->assertUnauthorized(); + } + + public function test_valid_application_key_can_list_tools_over_http(): void + { + $user = User::factory()->create(); + + $this->withApplicationKey($user) + ->postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ]) + ->assertOk() + ->assertJsonPath('result.tools.0.name', 'list_my_assigned_tickets') + ->assertJsonPath('result.tools.5.name', 'move_ticket_status'); + } + + public function test_list_my_assigned_tickets_returns_only_visible_current_user_tickets(): void + { + $user = User::factory()->create([ + 'name' => 'MCP User', + ]); + $otherUser = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Visible project'); + $hiddenProject = $this->project('HIDE', 'Hidden project'); + $visibleStatus = $visibleProject->ticketStatuses()->firstOrFail(); + $hiddenStatus = $hiddenProject->ticketStatuses()->firstOrFail(); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'assignee_user_id' => $user->id, + 'sequence_number' => 2, + 'ticket_key' => 'CORE-2', + 'title' => 'Visible assigned ticket', + ]); + $this->ticket($visibleProject, $visibleStatus, [ + 'assignee_user_id' => $otherUser->id, + 'sequence_number' => 3, + 'ticket_key' => 'CORE-3', + 'title' => 'Other user ticket', + ]); + $this->ticket($hiddenProject, $hiddenStatus, [ + 'assignee_user_id' => $user->id, + 'sequence_number' => 1, + 'ticket_key' => 'HIDE-1', + 'title' => 'Hidden assigned ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListMyAssignedTickets::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.assignee.name', 'MCP User') + ->where('data.0.project.name', 'Visible project') + ->where('data.0.tickets.0.ticket_key', 'CORE-2') + ->count('data.0.tickets', 1) + ->count('data', 1) + ->etc()); + } + + public function test_list_tickets_by_status_works_globally_and_with_project_filter(): void + { + $user = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Core project'); + $otherVisibleProject = $this->project('OPS', 'Ops project'); + $visibleStatus = $this->ticketStatus($visibleProject, 'Ready'); + $otherStatus = $this->ticketStatus($otherVisibleProject, 'Ready'); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + $this->syncProjectPermissions($otherVisibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'title' => 'Core ready ticket', + ]); + $this->ticket($otherVisibleProject, $otherStatus, [ + 'sequence_number' => 1, + 'ticket_key' => 'OPS-1', + 'title' => 'Ops ready ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $visibleStatus->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.statuses.0.id', $visibleStatus->id) + ->where('data.0.project.name', 'Core project') + ->where('data.0.tickets.0.ticket_key', 'CORE-1') + ->count('data', 1) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'project_id' => $otherVisibleProject->id, + 'status_name' => 'Ready', + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('meta.statuses.0.id', $otherStatus->id) + ->where('data.0.project.name', 'Ops project') + ->where('data.0.tickets.0.ticket_key', 'OPS-1') + ->count('data', 1) + ->etc()); + } + + public function test_status_ticket_lists_exclude_invisible_projects(): void + { + $user = User::factory()->create(); + $visibleProject = $this->project('CORE', 'Visible project'); + $hiddenProject = $this->project('HIDE', 'Hidden project'); + $visibleStatus = $this->ticketStatus($visibleProject, 'Visible Review'); + $hiddenStatus = $this->ticketStatus($hiddenProject, 'Hidden Review'); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->ticket($visibleProject, $visibleStatus, [ + 'ticket_key' => 'CORE-1', + 'title' => 'Visible status ticket', + ]); + $this->ticket($hiddenProject, $hiddenStatus, [ + 'ticket_key' => 'HIDE-1', + 'title' => 'Hidden status ticket', + ]); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $visibleStatus->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.0.tickets.0.ticket_key', 'CORE-1') + ->count('data', 1) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'ticket_status_id' => $hiddenStatus->id, + ]) + ->assertHasErrors(['Ticket status was not found in a visible project.']); + } + + public function test_get_ticket_returns_visible_ticket_detail(): void + { + $user = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'description' => 'Detailed MCP description', + 'ticket_key' => 'CORE-1', + 'title' => 'Detailed ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(GetTicket::class, [ + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.project.name', 'Core project') + ->where('data.ticket.ticket_key', 'CORE-1') + ->where('data.ticket.description', 'Detailed MCP description') + ->etc()); + } + + public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void + { + $user = User::factory()->create(); + $assignee = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'assignee_user_id' => $assignee->id, + 'description' => 'Old description', + 'story_points' => 8, + 'ticket_key' => 'CORE-1', + 'title' => 'Description ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketDescription::class, [ + 'description' => 'New description', + 'ticket_id' => $ticket->id, + ]) + ->assertHasErrors(['cannot update tickets']); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketDescription::class, [ + 'description' => 'New description', + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.description', 'New description') + ->where('data.ticket.assignee_user_id', $assignee->id) + ->where('data.ticket.story_points', 8) + ->etc()); + } + + public function test_assignee_update_supports_assigning_and_unassigning(): void + { + $user = User::factory()->create(); + $assignee = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $status = $project->ticketStatuses()->firstOrFail(); + $ticket = $this->ticket($project, $status, [ + 'assignee_user_id' => null, + 'ticket_key' => 'CORE-1', + 'title' => 'Assignee ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketAssignee::class, [ + 'assignee_user_id' => $assignee->id, + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.assignee_user_id', $assignee->id) + ->etc()); + + KanbanGitServer::actingAs($user) + ->tool(UpdateTicketAssignee::class, [ + 'assignee_user_id' => null, + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.assignee_user_id', null) + ->etc()); + } + + public function test_status_move_requires_write_permission_and_allowed_transition(): void + { + $user = User::factory()->create(); + $project = $this->project('CORE', 'Core project'); + $backlogStatus = $project->ticketStatuses()->firstOrFail(); + $doingStatus = $this->ticketStatus($project, 'Doing'); + $blockedStatus = $this->ticketStatus($project, 'Blocked'); + $ticket = $this->ticket($project, $backlogStatus, [ + 'ticket_key' => 'CORE-1', + 'title' => 'Move ticket', + ]); + + $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'ticket_id' => $ticket->id, + 'ticket_status_id' => $doingStatus->id, + ]) + ->assertHasErrors(['cannot update tickets']); + + $this->syncProjectPermissions($project, $user, ['write']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'ticket_id' => $ticket->id, + 'ticket_status_id' => $blockedStatus->id, + ]) + ->assertHasErrors(['allowed to move']); + + KanbanGitServer::actingAs($user) + ->tool(MoveTicketStatus::class, [ + 'status_slug' => 'doing', + 'ticket_id' => $ticket->id, + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.ticket.ticket_status_id', $doingStatus->id) + ->where('meta.moved', true) + ->where('meta.destination_label', 'Doing') + ->etc()); + } + + public function test_ambiguous_status_name_returns_mcp_error(): void + { + $user = User::factory()->create(); + $firstProject = $this->project('ONE', 'First project'); + $secondProject = $this->project('TWO', 'Second project'); + $this->ticketStatus($firstProject, 'Review'); + $this->ticketStatus($secondProject, 'Review'); + + $this->syncProjectPermissions($firstProject, $user, ['view']); + $this->syncProjectPermissions($secondProject, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class, [ + 'status_name' => 'Review', + ]) + ->assertHasErrors(['Status lookup is ambiguous']) + ->assertSee(['First project', 'Second project']); + } + + private function project(string $key, string $name): Project + { + $project = Project::factory()->create([ + 'key' => $key, + 'name' => $name, + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + return $project; + } + + private function ticketStatus(Project $project, string $name): TicketStatus + { + $position = $project->boardColumns()->count() + 1; + $boardColumn = BoardColumn::factory()->for($project)->create([ + 'name' => $name, + 'position' => $position, + ]); + + return TicketStatus::factory() + ->for($project) + ->for($boardColumn) + ->create([ + 'name' => $name, + 'position' => $position, + ]); + } + + /** + * @param array $attributes + */ + private function ticket(Project $project, TicketStatus $ticketStatus, array $attributes = []): Ticket + { + $priority = $project->ticketPriorities()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + return Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => "{$project->key}-1", + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $ticketStatus->id, + 'ticket_type_id' => $type->id, + 'title' => 'MCP ticket', + ...$attributes, + ]); + } +}