Add MCP tool to list project ticket types
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-09 07:44:41 +00:00
parent bd774e20f6
commit ce01b8ec55
3 changed files with 223 additions and 1 deletions
+3 -1
View File
@@ -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 = [
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketTypeResource;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
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('list_ticket_types')]
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
#[IsReadOnly]
class ListTicketTypes extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()
->description('Project ID. Requires permission to view this project.')
->required(),
];
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace Tests\Feature\Mcp;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\ListTicketTypes;
use App\Models\Project;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class ListTicketTypesTest extends TestCase
{
use RefreshDatabase;
public function test_application_key_can_discover_the_read_only_tool_and_required_project_input(): void
{
$user = User::factory()->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<string, mixed> $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<string, array{array<string, mixed>}>
*/
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]]],
];
}
}