diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md new file mode 100644 index 0000000..0871003 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -0,0 +1,356 @@ +# MCP Server for Lesstime — Design Spec + +**Date**: 2026-03-15 +**Status**: Draft +**Scope**: Expose projects, tasks, and time tracking via MCP for AI clients (Claude Code local first) + +## Context + +Lesstime is a project management app (Symfony 8 + API Platform 4). We want AI assistants to interact with projects, tasks, and time entries via the Model Context Protocol (MCP). + +**Phase 1 (this spec)**: STDIO transport for Claude Code local usage. +**Phase 2 (future)**: HTTP transport + API token auth + Cloudflare Tunnel for remote clients (Claude Web, ChatGPT, Codex). + +## Technology Choice + +**`symfony/mcp-bundle`** — the official Symfony MCP bundle, maintained by Symfony + PHP Foundation + Anthropic. Uses PHP attributes (`#[McpTool]`) for auto-discovery. + +## Architecture + +### File Structure + +``` +src/Mcp/ +├── Tool/ +│ ├── Project/ +│ │ ├── ListProjectsTool.php +│ │ ├── GetProjectTool.php +│ │ └── CreateProjectTool.php +│ ├── Task/ +│ │ ├── ListTasksTool.php +│ │ ├── GetTaskTool.php +│ │ ├── CreateTaskTool.php +│ │ ├── UpdateTaskTool.php +│ │ └── DeleteTaskTool.php +│ ├── TaskMeta/ +│ │ ├── ListStatusesTool.php +│ │ ├── ListPrioritiesTool.php +│ │ ├── ListEffortsTool.php +│ │ ├── ListTagsTool.php +│ │ └── ListGroupsTool.php +│ └── TimeEntry/ +│ ├── ListTimeEntriesTool.php +│ ├── CreateTimeEntryTool.php +│ ├── UpdateTimeEntryTool.php +│ └── DeleteTimeEntryTool.php +``` + +### Configuration + +```yaml +# config/packages/mcp.yaml +mcp: + app: 'lesstime' + version: '1.0.0' + description: 'Lesstime project management — projects, tasks, time tracking' + instructions: | + This server provides access to the Lesstime project management system. + You can list/create/update/delete projects, tasks, and time entries. + Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Time entries track work duration and can be linked to projects and tasks. + Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover + available metadata before creating or updating tasks. + client_transports: + stdio: true + http: false # Phase 2 +``` + +### Claude Code Configuration + +```json +// .claude/settings.json or project settings +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"], + "cwd": "/home/r-dev/Lesstime" + } + } +} +``` + +Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses `docker exec` to run inside the container. + +## Tools Specification + +### Project Tools + +#### `list-projects` +- **Description**: List all projects with optional archive filter +- **Parameters**: `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, code, name, description, color, client: { id, name } | null, archived }` +- **Implementation**: `ProjectRepository::findBy(['archived' => $archived], ['name' => 'ASC'])` + +#### `get-project` +- **Description**: Get project details with task count summary per status +- **Parameters**: `id` (int, required) +- **Returns**: `{ id, code, name, description, color, client, archived, taskSummary: { statusLabel: count, ... }, totalTasks }` +- **Implementation**: `ProjectRepository::find($id)` + DQL count query grouped by status + +#### `create-project` +- **Description**: Create a new project +- **Parameters**: `name` (string, required), `code` (string, required, 2-10 uppercase letters), `description` (string, optional), `color` (string, optional), `clientId` (int, optional) +- **Returns**: Created project object +- **Implementation**: Create `Project` entity, persist via `EntityManager` + +### Task Tools + +#### `list-tasks` +- **Description**: List tasks with filters +- **Parameters**: + - `projectId` (int, optional) — filter by project + - `statusId` (int, optional) — filter by status + - `assigneeId` (int, optional) — filter by assignee + - `priorityId` (int, optional) — filter by priority + - `groupId` (int, optional) — filter by group + - `tagIds` (int[], optional) — filter by tags + - `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, number, title, status: { id, label, color }, priority: { id, label, color } | null, assignee: { id, username } | null, effort: { id, label } | null, group: { id, title } | null, project: { id, code, name }, tags: [{ id, label }], archived }` +- **Implementation**: `TaskRepository` with QueryBuilder and conditional filters + +#### `get-task` +- **Description**: Get full task details +- **Parameters**: `id` (int, required) +- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size }], archived }` +- **Implementation**: `TaskRepository::find($id)` with eager loading + +#### `create-task` +- **Description**: Create a new task (number auto-generated per project) +- **Parameters**: + - `projectId` (int, required) + - `title` (string, required) + - `description` (string, optional) + - `statusId` (int, optional) + - `priorityId` (int, optional) + - `effortId` (int, optional) + - `assigneeId` (int, optional) + - `groupId` (int, optional) + - `tagIds` (int[], optional) +- **Returns**: Created task with auto-generated number +- **Implementation**: Create `Task` entity, compute number via `TaskRepository::findMaxNumberByProject()` + 1, set relations, persist + +#### `update-task` +- **Description**: Update an existing task (partial update, only provided fields are changed) +- **Parameters**: + - `id` (int, required) + - `title` (string, optional) + - `description` (string, optional) + - `statusId` (int, optional) + - `priorityId` (int, optional) + - `effortId` (int, optional) + - `assigneeId` (int, optional) + - `groupId` (int, optional) + - `tagIds` (int[], optional) + - `archived` (bool, optional) +- **Returns**: Updated task object +- **Implementation**: Find task, apply changes, flush + +#### `delete-task` +- **Description**: Delete a task permanently +- **Parameters**: `id` (int, required) +- **Returns**: `{ success: true, message: "Task PROJECT-123 deleted" }` +- **Implementation**: `EntityManager::remove()` + flush (cascade deletes documents) + +### TaskMeta Tools (Read-Only) + +#### `list-statuses` +- **Description**: List all task statuses (needed to create/update tasks) +- **Returns**: Array of `{ id, label, color, position, isFinal }` +- **Implementation**: `TaskStatusRepository::findBy([], ['position' => 'ASC'])` + +#### `list-priorities` +- **Description**: List all task priorities +- **Returns**: Array of `{ id, label, color }` +- **Implementation**: `TaskPriorityRepository::findBy([], ['label' => 'ASC'])` + +#### `list-efforts` +- **Description**: List all task effort levels +- **Returns**: Array of `{ id, label }` +- **Implementation**: `TaskEffortRepository::findBy([], ['label' => 'ASC'])` + +#### `list-tags` +- **Description**: List all task tags +- **Returns**: Array of `{ id, label, color }` +- **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])` + +#### `list-groups` +- **Description**: List task groups, optionally filtered by project +- **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }` +- **Implementation**: `TaskGroupRepository` with optional project filter + +### TimeEntry Tools + +#### `list-time-entries` +- **Description**: List time entries with filters +- **Parameters**: + - `userId` (int, optional) + - `projectId` (int, optional) + - `startDate` (string, optional, format: YYYY-MM-DD) + - `endDate` (string, optional, format: YYYY-MM-DD) +- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration (minutes), user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }` +- **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt` + +#### `create-time-entry` +- **Description**: Create a time entry +- **Parameters**: + - `title` (string, required) + - `startedAt` (string, required, ISO 8601) + - `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer) + - `projectId` (int, optional) + - `taskId` (int, optional) + - `tagIds` (int[], optional) + - `description` (string, optional) + - `userId` (int, required) +- **Returns**: Created time entry +- **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null. + +#### `update-time-entry` +- **Description**: Update a time entry (e.g., stop a running timer) +- **Parameters**: + - `id` (int, required) + - `title` (string, optional) + - `stoppedAt` (string, optional, ISO 8601) + - `projectId` (int, optional) + - `taskId` (int, optional) + - `tagIds` (int[], optional) + - `description` (string, optional) +- **Returns**: Updated time entry +- **Implementation**: Find entry, apply changes, flush + +#### `delete-time-entry` +- **Description**: Delete a time entry +- **Parameters**: `id` (int, required) +- **Returns**: `{ success: true, message: "Time entry deleted" }` +- **Implementation**: `EntityManager::remove()` + flush + +## Tool Return Format + +All tools return JSON strings. For consistency: + +- **List tools**: Return a JSON array of objects +- **Get/Create/Update tools**: Return a single JSON object +- **Delete tools**: Return `{ success: true, message: "..." }` +- **Errors**: Throw exceptions (the MCP bundle handles error responses) + +Example tool implementation pattern: + +```php +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC'); + + if ($projectId !== null) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if ($statusId !== null) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if ($assigneeId !== null) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if ($priorityId !== null) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if ($groupId !== null) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + // Filter by tags in PHP (ManyToMany not easily filterable in DQL) + if ($tagIds !== null) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray(); + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} +``` + +## Installation Steps + +1. `composer require symfony/mcp-bundle` (inside Docker container) +2. Create `config/packages/mcp.yaml` with STDIO transport +3. Add MCP route: `config/routes/mcp.yaml` +4. Create tool classes in `src/Mcp/Tool/` +5. Test with `php bin/console mcp:server` (STDIO) +6. Configure Claude Code settings to point to the MCP server + +## Phase 2 (Future) + +When ready for remote clients: + +1. Enable HTTP transport on `/_mcp` +2. Add `apiToken` field on `User` entity + migration +3. Create `ApiTokenAuthenticator` (Symfony custom authenticator) +4. Add firewall rule for `/_mcp` path +5. Set up Cloudflare Tunnel for external access +6. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token