From 9003bcd58ddab8061cf8e0d4a63a60b7d3b07cfb Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 9 Sep 2026 09:09:38 +0200 Subject: [PATCH] Add MCP tool to list project ticket types --- app/Mcp/Servers/KanbanGitServer.php | 4 +- app/Mcp/Tools/ListTicketTypes.php | 56 ++++++++ tests/Feature/Mcp/ListTicketTypesTest.php | 164 ++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 app/Mcp/Tools/ListTicketTypes.php create mode 100644 tests/Feature/Mcp/ListTicketTypesTest.php diff --git a/app/Mcp/Servers/KanbanGitServer.php b/app/Mcp/Servers/KanbanGitServer.php index 5c6d7b4..a54fcb1 100644 --- a/app/Mcp/Servers/KanbanGitServer.php +++ b/app/Mcp/Servers/KanbanGitServer.php @@ -6,6 +6,7 @@ use App\Mcp\Tools\CreateTicket; use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListTicketsByStatus; +use App\Mcp\Tools\ListTicketTypes; use App\Mcp\Tools\MoveTicketStatus; use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketDescription; @@ -16,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version; #[Name('KanbanGit MCP')] #[Version('0.1.0')] -#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use create_ticket with parent_ticket_id to create a child ticket.')] +#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')] class KanbanGitServer extends Server { protected array $tools = [ @@ -27,6 +28,7 @@ class KanbanGitServer extends Server UpdateTicketAssignee::class, MoveTicketStatus::class, CreateTicket::class, + ListTicketTypes::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/ListTicketTypes.php b/app/Mcp/Tools/ListTicketTypes.php new file mode 100644 index 0000000..0d2c471 --- /dev/null +++ b/app/Mcp/Tools/ListTicketTypes.php @@ -0,0 +1,56 @@ +validate([ + 'project_id' => ['required', 'integer'], + ]); + + $user = $this->kanbanGitMcp->user($request); + $project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id')); + + return Response::structured([ + 'data' => [ + 'project' => ProjectResource::make($project)->resolve(), + 'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), + ], + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer() + ->description('Project ID. Requires permission to view this project.') + ->required(), + ]; + } +} diff --git a/tests/Feature/Mcp/ListTicketTypesTest.php b/tests/Feature/Mcp/ListTicketTypesTest.php new file mode 100644 index 0000000..ff03ea7 --- /dev/null +++ b/tests/Feature/Mcp/ListTicketTypesTest.php @@ -0,0 +1,164 @@ +create(); + + $response = $this->withApplicationKey($user)->postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => [], + ])->assertOk(); + + $tool = collect($response->json('result.tools'))->firstWhere('name', 'list_ticket_types'); + + $this->assertNotNull($tool); + $this->assertTrue($tool['annotations']['readOnlyHint']); + $this->assertSame(['project_id'], $tool['inputSchema']['required']); + $this->assertSame('integer', $tool['inputSchema']['properties']['project_id']['type']); + } + + public function test_project_viewer_can_list_all_project_types_in_configured_order_over_http(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $otherProject = Project::factory()->create(); + $taskType = TicketType::factory()->for($project)->create([ + 'name' => 'Task', + 'is_parent' => false, + 'position' => 2, + ]); + $parentType = TicketType::factory()->for($project)->create([ + 'name' => 'Release', + 'is_parent' => true, + 'position' => 1, + ]); + TicketType::factory()->for($otherProject)->create(['name' => 'Foreign type']); + $this->syncProjectPermissions($project, $user, ['view']); + $this->syncProjectPermissions($otherProject, $user, ['view']); + + $this->withApplicationKey($user)->postJson('/mcp/kanbangit', [ + 'id' => 2, + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'list_ticket_types', + 'arguments' => ['project_id' => $project->id], + ], + ]) + ->assertOk() + ->assertJsonPath('result.isError', false) + ->assertJsonPath('result.structuredContent.data.project.id', $project->id) + ->assertJsonCount(2, 'result.structuredContent.data.ticket_types') + ->assertJsonPath('result.structuredContent.data.ticket_types.0.id', $parentType->id) + ->assertJsonPath('result.structuredContent.data.ticket_types.0.name', 'Release') + ->assertJsonPath('result.structuredContent.data.ticket_types.0.is_parent', true) + ->assertJsonPath('result.structuredContent.data.ticket_types.0.position', 1) + ->assertJsonPath('result.structuredContent.data.ticket_types.0.project_id', $project->id) + ->assertJsonPath('result.structuredContent.data.ticket_types.1.id', $taskType->id) + ->assertJsonPath('result.structuredContent.data.ticket_types.1.name', 'Task') + ->assertJsonPath('result.structuredContent.data.ticket_types.1.is_parent', false); + + $this->assertDatabaseCount('ticket_types', 3); + $this->assertDatabaseCount('tickets', 0); + } + + public function test_invisible_project_types_are_not_accessible(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + TicketType::factory()->for($project)->create(); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketTypes::class, ['project_id' => $project->id]) + ->assertHasErrors(['You cannot view this project.']); + } + + public function test_missing_project_returns_an_actionable_error(): void + { + $user = User::factory()->create(); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketTypes::class, ['project_id' => 999999]) + ->assertHasErrors(['Project was not found.']); + } + + public function test_listing_requires_authentication(): void + { + $project = Project::factory()->create(); + + KanbanGitServer::tool(ListTicketTypes::class, ['project_id' => $project->id]) + ->assertHasErrors(['Authentication required.']); + + $this->postJson('/mcp/kanbangit', [ + 'id' => 1, + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'list_ticket_types', + 'arguments' => ['project_id' => $project->id], + ], + ])->assertUnauthorized(); + } + + public function test_project_without_types_returns_an_empty_list(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $this->syncProjectPermissions($project, $user, ['view']); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketTypes::class, ['project_id' => $project->id]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.project.id', $project->id) + ->where('data.ticket_types', []) + ->etc()); + + $this->assertDatabaseCount('ticket_types', 0); + } + + /** + * @param array $arguments + */ + #[DataProvider('invalidProjectArguments')] + public function test_invalid_project_arguments_are_rejected(array $arguments): void + { + $user = User::factory()->create(); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketTypes::class, $arguments) + ->assertHasErrors(); + } + + /** + * @return array}> + */ + public static function invalidProjectArguments(): array + { + return [ + 'missing project' => [[]], + 'null project' => [['project_id' => null]], + 'project name instead of ID' => [['project_id' => 'CORE']], + 'fractional ID' => [['project_id' => 1.5]], + 'array instead of ID' => [['project_id' => [1]]], + ]; + } +}