From 8d2494918639d5c3c98802abf75a57981723c638 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:11:02 +0100 Subject: [PATCH] docs : update MCP server spec with review fixes Adds list-users, list-clients, update-project tools. Fixes time entry title as optional, adds startedAt to update-time-entry, adds taskId filter, pagination limits, eager joins, security model docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-mcp-server-design.md | 120 ++++++++++++++---- 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md index 0871003..5ebdc91 100644 --- a/docs/superpowers/specs/2026-03-15-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -25,7 +25,8 @@ src/Mcp/ │ ├── Project/ │ │ ├── ListProjectsTool.php │ │ ├── GetProjectTool.php -│ │ └── CreateProjectTool.php +│ │ ├── CreateProjectTool.php +│ │ └── UpdateProjectTool.php │ ├── Task/ │ │ ├── ListTasksTool.php │ │ ├── GetTaskTool.php @@ -38,11 +39,14 @@ src/Mcp/ │ │ ├── ListEffortsTool.php │ │ ├── ListTagsTool.php │ │ └── ListGroupsTool.php -│ └── TimeEntry/ -│ ├── ListTimeEntriesTool.php -│ ├── CreateTimeEntryTool.php -│ ├── UpdateTimeEntryTool.php -│ └── DeleteTimeEntryTool.php +│ ├── TimeEntry/ +│ │ ├── ListTimeEntriesTool.php +│ │ ├── CreateTimeEntryTool.php +│ │ ├── UpdateTimeEntryTool.php +│ │ └── DeleteTimeEntryTool.php +│ └── Reference/ +│ ├── ListUsersTool.php +│ └── ListClientsTool.php ``` ### Configuration @@ -57,9 +61,12 @@ mcp: 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. + Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). + Groups are PER-PROJECT (each group belongs to one project). 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. + Use list-users and list-clients to discover valid user and client IDs. client_transports: stdio: true http: false # Phase 2 @@ -82,8 +89,26 @@ mcp: Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses `docker exec` to run inside the container. +### Security Model (Phase 1) + +Phase 1 uses STDIO transport only (Claude Code local). The console command runs without a Symfony security context. All tools run with **full privileges** (equivalent to ROLE_ADMIN), since only the local developer has access. No authentication is needed. + +Phase 2 will add API token authentication on the HTTP transport. + ## Tools Specification +### Reference Tools (ID Discovery) + +#### `list-users` +- **Description**: List all users (needed to resolve assignee/user IDs) +- **Returns**: Array of `{ id, username }` +- **Implementation**: `UserRepository::findBy([], ['username' => 'ASC'])` + +#### `list-clients` +- **Description**: List all clients (needed to resolve client IDs for projects) +- **Returns**: Array of `{ id, name, email }` +- **Implementation**: `ClientRepository::findBy([], ['name' => 'ASC'])` + ### Project Tools #### `list-projects` @@ -104,10 +129,23 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Returns**: Created project object - **Implementation**: Create `Project` entity, persist via `EntityManager` +#### `update-project` +- **Description**: Update an existing project (partial update) +- **Parameters**: + - `id` (int, required) + - `name` (string, optional) + - `code` (string, optional) + - `description` (string, optional) + - `color` (string, optional) + - `clientId` (int, optional) + - `archived` (bool, optional) +- **Returns**: Updated project object +- **Implementation**: Find project, apply changes, flush + ### Task Tools #### `list-tasks` -- **Description**: List tasks with filters +- **Description**: List tasks with filters. Returns max 100 results, use filters to narrow down. - **Parameters**: - `projectId` (int, optional) — filter by project - `statusId` (int, optional) — filter by status @@ -116,13 +154,14 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - `groupId` (int, optional) — filter by group - `tagIds` (int[], optional) — filter by tags - `archived` (bool, optional, default: false) + - `limit` (int, optional, default: 100, max: 200) - **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 +- **Implementation**: `TaskRepository` with QueryBuilder, conditional filters, and `setMaxResults($limit)`. Joins must include all relations: status, priority, assignee, project, effort, group, tags. #### `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 }` +- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size, createdAt, uploadedBy: { id, username } }], archived }` - **Implementation**: `TaskRepository::find($id)` with eager loading #### `create-task` @@ -138,7 +177,7 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - `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 +- **Implementation**: Create `Task` entity, reuse `TaskRepository::findMaxNumberByProject()` for number generation (same logic as `TaskNumberProcessor`), set relations, persist #### `update-task` - **Description**: Update an existing task (partial update, only provided fields are changed) @@ -164,6 +203,8 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses ### TaskMeta Tools (Read-Only) +Statuses, priorities, efforts, and tags are **global** (shared across all projects). Groups are **per-project**. + #### `list-statuses` - **Description**: List all task statuses (needed to create/update tasks) - **Returns**: Array of `{ id, label, color, position, isFinal }` @@ -185,7 +226,7 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])` #### `list-groups` -- **Description**: List task groups, optionally filtered by project +- **Description**: List task groups, optionally filtered by project. Groups are per-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 @@ -197,36 +238,41 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Parameters**: - `userId` (int, optional) - `projectId` (int, optional) + - `taskId` (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 }] }` + - `limit` (int, optional, default: 100, max: 200) +- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration, user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }` +- **Note**: `duration` is computed from `stoppedAt - startedAt` in minutes. Returns `null` for active timers (stoppedAt is null). - **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt` #### `create-time-entry` - **Description**: Create a time entry - **Parameters**: - - `title` (string, required) + - `userId` (int, required) - `startedAt` (string, required, ISO 8601) + - `title` (string, optional) - `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) +- **Description**: Update a time entry (e.g., stop a running timer, correct start time) - **Parameters**: - `id` (int, required) - `title` (string, optional) + - `startedAt` (string, optional, ISO 8601) - `stoppedAt` (string, optional, ISO 8601) - `projectId` (int, optional) - `taskId` (int, optional) - `tagIds` (int[], optional) - `description` (string, optional) - **Returns**: Updated time entry +- **Note**: `userId` is intentionally not updatable via MCP. Reassigning time entries to another user should be done through the app UI. - **Implementation**: Find entry, apply changes, flush #### `delete-time-entry` @@ -243,6 +289,7 @@ All tools return JSON strings. For consistency: - **Get/Create/Update tools**: Return a single JSON object - **Delete tools**: Return `{ success: true, message: "..." }` - **Errors**: Throw exceptions (the MCP bundle handles error responses) +- **Duration**: Computed field (minutes), `null` for active timers Example tool implementation pattern: @@ -272,15 +319,22 @@ class ListTasksTool ?int $groupId = null, ?array $tagIds = null, bool $archived = false, + int $limit = 100, ): string { + $limit = min($limit, 200); + $qb = $this->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') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') ->where('t.archived = :archived') ->setParameter('archived', $archived) - ->orderBy('t.id', 'DESC'); + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit); if ($projectId !== null) { $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); @@ -315,20 +369,34 @@ class ListTasksTool 'status' => $task->getStatus() ? [ 'id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), ] : null, 'priority' => $task->getPriority() ? [ 'id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), ] : null, 'assignee' => $task->getAssignee() ? [ 'id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername(), ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, 'project' => [ 'id' => $task->getProject()->getId(), 'code' => $task->getProject()->getCode(), 'name' => $task->getProject()->getName(), ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), 'archived' => $task->isArchived(), ], array_values($tasks))); } @@ -339,18 +407,20 @@ class ListTasksTool 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 +3. Create tool classes in `src/Mcp/Tool/` +4. Test with `php bin/console mcp:server` (STDIO) +5. Configure Claude Code settings to point to the MCP server + +Note: STDIO transport does not need HTTP routes. Routes are only needed for Phase 2 (HTTP transport). ## 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 +2. Add MCP route: `config/routes/mcp.yaml` +3. Add `apiToken` field on `User` entity + migration +4. Create `ApiTokenAuthenticator` (Symfony custom authenticator) +5. Add firewall rule for `/_mcp` path +6. Set up Cloudflare Tunnel for external access +7. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token