Add MCP tool to list project ticket types
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Mcp\Tools\CreateTicket;
|
|||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||||
use App\Mcp\Tools\ListTicketsByStatus;
|
use App\Mcp\Tools\ListTicketsByStatus;
|
||||||
|
use App\Mcp\Tools\ListTicketTypes;
|
||||||
use App\Mcp\Tools\MoveTicketStatus;
|
use App\Mcp\Tools\MoveTicketStatus;
|
||||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||||
use App\Mcp\Tools\UpdateTicketDescription;
|
use App\Mcp\Tools\UpdateTicketDescription;
|
||||||
@@ -16,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
|
|||||||
|
|
||||||
#[Name('KanbanGit MCP')]
|
#[Name('KanbanGit MCP')]
|
||||||
#[Version('0.1.0')]
|
#[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
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
@@ -27,6 +28,7 @@ class KanbanGitServer extends Server
|
|||||||
UpdateTicketAssignee::class,
|
UpdateTicketAssignee::class,
|
||||||
MoveTicketStatus::class,
|
MoveTicketStatus::class,
|
||||||
CreateTicket::class,
|
CreateTicket::class,
|
||||||
|
ListTicketTypes::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $resources = [
|
protected array $resources = [
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user