Compare commits

..
1 Commits
Author SHA1 Message Date
Twyco 9577d06c9b Add MCP ticket creation with full fields and parent support
CI/CD / test_and_check_app (push) Failing after 2m17s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 2m11s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-09 08:33:07 +02:00
4 changed files with 17 additions and 254 deletions
+1 -3
View File
@@ -6,7 +6,6 @@ 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;
@@ -17,7 +16,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 list_ticket_types to discover the available ticket types in a project. 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 create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server
{
protected array $tools = [
@@ -28,7 +27,6 @@ class KanbanGitServer extends Server
UpdateTicketAssignee::class,
MoveTicketStatus::class,
CreateTicket::class,
ListTicketTypes::class,
];
protected array $resources = [
-56
View File
@@ -1,56 +0,0 @@
<?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(),
];
}
}
+16 -31
View File
@@ -4,7 +4,6 @@
"requires": true,
"packages": {
"": {
"name": "KanbanGit",
"dependencies": {
"@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2",
@@ -418,43 +417,29 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -4603,9 +4588,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -5460,9 +5445,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5619,9 +5604,9 @@
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
-164
View File
@@ -1,164 +0,0 @@
<?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]]],
];
}
}