Add KanbanGit MCP v0.1 #40

Merged
Twyco merged 4 commits from codex/mcp-v0-1 into main 2026-04-24 13:27:36 +00:00
15 changed files with 1303 additions and 12 deletions
@@ -20,10 +20,16 @@ class ListAssignedTicketsByVisibleProject
/** /**
* @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}> * @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}>
*/ */
public function handle(User $viewer, User $assignee): array public function handle(User $viewer, User $assignee, ?Project $project = null): array
{ {
/** @var EloquentCollection<int, Project> $visibleProjects */ /** @var EloquentCollection<int, Project> $visibleProjects */
$visibleProjects = $this->listVisibleProjects->handle($viewer); $visibleProjects = $this->listVisibleProjects->handle($viewer);
if ($project !== null) {
$visibleProjects = $visibleProjects
->where('id', $project->id)
->values();
}
$visibleProjectIds = $visibleProjects->pluck('id')->all(); $visibleProjectIds = $visibleProjects->pluck('id')->all();
if ($visibleProjectIds === []) { if ($visibleProjectIds === []) {
@@ -0,0 +1,67 @@
<?php
namespace App\Actions\Tickets;
use App\Actions\Projects\ListVisibleProjects;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketResource;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class ListTicketsByStatusByVisibleProject
{
public function __construct(
private readonly ListVisibleProjects $listVisibleProjects,
) {}
/**
* @param Collection<int, TicketStatus> $ticketStatuses
* @return array<int, array{project: array<string, mixed>, tickets: array<int, array<string, mixed>>}>
*/
public function handle(User $viewer, Collection $ticketStatuses, ?Project $project = null): array
{
/** @var EloquentCollection<int, Project> $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<int, Collection<int, Ticket>> $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();
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')]
#[Version('0.1.0')]
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
class KanbanGitServer extends Server
{
protected array $tools = [
ListMyAssignedTickets::class,
ListTicketsByStatus::class,
GetTicket::class,
UpdateTicketDescription::class,
UpdateTicketAssignee::class,
MoveTicketStatus::class,
];
protected array $resources = [
//
];
protected array $prompts = [
//
];
}
+239
View File
@@ -0,0 +1,239 @@
<?php
namespace App\Mcp\Support;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketResource;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
class KanbanGitMcp
{
public function __construct(
private readonly PermissionService $permissionService,
) {}
/**
* @throws AuthenticationException
*/
public function user(Request $request): User
{
$user = $request->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<string, mixed> $validated
* @return Collection<int, TicketStatus>|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<string, mixed>
*/
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,
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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('get_ticket')]
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
#[IsReadOnly]
class GetTicket extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'ticket_id' => $schema->integer()
->description('Ticket ID.')
->required(),
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use App\Http\Resources\UserResource;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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_my_assigned_tickets')]
#[Description('List tickets assigned to the authenticated application key owner, grouped by visible project. Optionally filter to one project.')]
#[IsReadOnly]
class ListMyAssignedTickets extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
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.'),
];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\ListTicketsByStatusByVisibleProject;
use App\Http\Resources\TicketStatusResource;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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_tickets_by_status')]
#[Description('List tickets in a status, grouped by visible project. Use ticket_status_id when possible; status_slug or status_name are convenience lookups and must be unambiguous.')]
#[IsReadOnly]
class ListTicketsByStatus extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly ListTicketsByStatusByVisibleProject $listTicketsByStatusByVisibleProject,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'project_id' => ['nullable', 'integer'],
'status_name' => ['nullable', 'string'],
'status_slug' => ['nullable', 'string'],
'ticket_status_id' => ['nullable', 'integer', 'required_without_all:status_slug,status_name'],
Outdated
Review

Should fix: this tool schema accepts a call with none of ticket_status_id, status_slug, or status_name, and only fails later inside resolveTicketStatuses(). That makes the MCP contract looser than the real behavior. Please enforce an at least one of rule here (or equivalent explicit validation) so clients get a schema-level/tool-level error instead of discovering it only at runtime.

Should fix: this tool schema accepts a call with none of `ticket_status_id`, `status_slug`, or `status_name`, and only fails later inside `resolveTicketStatuses()`. That makes the MCP contract looser than the real behavior. Please enforce an `at least one of` rule here (or equivalent explicit validation) so clients get a schema-level/tool-level error instead of discovering it only at runtime.
], [
'ticket_status_id.required_without_all' => 'Provide ticket_status_id, status_slug, or status_name.',
]);
$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<string, Type>
*/
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.'),
];
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\MoveProjectTicket;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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\IsDestructive;
#[Name('move_ticket_status')]
#[Description('Move a ticket to an allowed target status. Use ticket_status_id when possible; status_slug or status_name are convenience lookups and must resolve to one status in the ticket project.')]
#[IsDestructive(false)]
class MoveTicketStatus extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly MoveProjectTicket $moveProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
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.'),
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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\IsDestructive;
#[Name('update_ticket_assignee')]
#[Description('Assign or unassign a ticket while preserving all other ticket fields. Requires project write permission.')]
#[IsDestructive(false)]
class UpdateTicketAssignee extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
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(),
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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\IsDestructive;
#[Name('update_ticket_description')]
#[Description('Update only a ticket description while preserving all other ticket fields. Requires project write permission.')]
#[IsDestructive(false)]
class UpdateTicketDescription extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->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<string, Type>
*/
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(),
];
}
}
+2 -4
View File
@@ -117,11 +117,9 @@ return [
*/ */
'hideClientButton' => true, 'hideClientButton' => true,
/**
* MCP will be introduced as a first-party KanbanGit feature later.
*/
'mcp' => [ 'mcp' => [
'disabled' => true, 'name' => 'KanbanGit MCP',
'url' => '/mcp/kanbangit',
], ],
/** /**
+125
View File
@@ -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-key>`
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 <application-key>
```
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
+8
View File
@@ -0,0 +1,8 @@
<?php
use App\Http\Middleware\AuthenticateApiApplicationKey;
use App\Mcp\Servers\KanbanGitServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/kanbangit', KanbanGitServer::class)
->middleware(AuthenticateApiApplicationKey::class);
@@ -8,13 +8,11 @@ class PublicApiDocumentationTest extends TestCase
{ {
public function test_scalar_api_docs_are_public(): void public function test_scalar_api_docs_are_public(): void
{ {
$response = $this->get('/api/docs') $this->get('/api/docs')
->assertOk(); ->assertOk()
->assertSee('"hideClientButton":true', false)
$content = (string) $response->getContent(); ->assertSee('"hideTestRequestButton":true', false)
->assertSee('"mcp":{"name":"KanbanGit MCP","url":"\/mcp\/kanbangit"}', false);
$this->assertMatchesRegularExpression('/"hideClientButton"\s*:\s*true/', $content);
$this->assertMatchesRegularExpression('/"mcp"\s*:\s*\{[^}]*"disabled"\s*:\s*true/', $content);
} }
public function test_openapi_document_is_publicly_served(): void public function test_openapi_document_is_publicly_served(): void
+398
View File
@@ -0,0 +1,398 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
class KanbanGitMcpTest extends TestCase
{
use RefreshDatabase;
public function test_unauthenticated_http_mcp_endpoint_returns_unauthorized(): void
{
$this->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
Twyco marked this conversation as resolved
Review

Nit/Optional: since the missing-selector error is now part of the effective tool contract, I'd add a focused test for calling ListTicketsByStatus without ticket_status_id, status_slug, or status_name and asserting the expected MCP error. That keeps the validation behavior from drifting later.

Nit/Optional: since the missing-selector error is now part of the effective tool contract, I'd add a focused test for calling `ListTicketsByStatus` without `ticket_status_id`, `status_slug`, or `status_name` and asserting the expected MCP error. That keeps the validation behavior from drifting later.
{
$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_list_tickets_by_status_requires_a_status_selector(): void
{
$user = User::factory()->create();
KanbanGitServer::actingAs($user)
->tool(ListTicketsByStatus::class)
->assertHasErrors(['Provide ticket_status_id, status_slug, or status_name.']);
}
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<string, mixed> $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,
]);
}
}