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) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:11:02 +01:00
parent c2fa308f1e
commit 8d24949186

View File

@@ -25,7 +25,8 @@ src/Mcp/
│ ├── Project/ │ ├── Project/
│ │ ├── ListProjectsTool.php │ │ ├── ListProjectsTool.php
│ │ ├── GetProjectTool.php │ │ ├── GetProjectTool.php
│ │ ── CreateProjectTool.php │ │ ── CreateProjectTool.php
│ │ └── UpdateProjectTool.php
│ ├── Task/ │ ├── Task/
│ │ ├── ListTasksTool.php │ │ ├── ListTasksTool.php
│ │ ├── GetTaskTool.php │ │ ├── GetTaskTool.php
@@ -38,11 +39,14 @@ src/Mcp/
│ │ ├── ListEffortsTool.php │ │ ├── ListEffortsTool.php
│ │ ├── ListTagsTool.php │ │ ├── ListTagsTool.php
│ │ └── ListGroupsTool.php │ │ └── ListGroupsTool.php
── TimeEntry/ ── TimeEntry/
├── ListTimeEntriesTool.php ├── ListTimeEntriesTool.php
├── CreateTimeEntryTool.php ├── CreateTimeEntryTool.php
├── UpdateTimeEntryTool.php ├── UpdateTimeEntryTool.php
└── DeleteTimeEntryTool.php └── DeleteTimeEntryTool.php
│ └── Reference/
│ ├── ListUsersTool.php
│ └── ListClientsTool.php
``` ```
### Configuration ### Configuration
@@ -57,9 +61,12 @@ mcp:
This server provides access to the Lesstime project management system. This server provides access to the Lesstime project management system.
You can list/create/update/delete projects, tasks, and time entries. You can list/create/update/delete projects, tasks, and time entries.
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. 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. 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 Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
available metadata before creating or updating tasks. available metadata before creating or updating tasks.
Use list-users and list-clients to discover valid user and client IDs.
client_transports: client_transports:
stdio: true stdio: true
http: false # Phase 2 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. 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 ## 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 ### Project Tools
#### `list-projects` #### `list-projects`
@@ -104,10 +129,23 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses
- **Returns**: Created project object - **Returns**: Created project object
- **Implementation**: Create `Project` entity, persist via `EntityManager` - **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 ### Task Tools
#### `list-tasks` #### `list-tasks`
- **Description**: List tasks with filters - **Description**: List tasks with filters. Returns max 100 results, use filters to narrow down.
- **Parameters**: - **Parameters**:
- `projectId` (int, optional) — filter by project - `projectId` (int, optional) — filter by project
- `statusId` (int, optional) — filter by status - `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 - `groupId` (int, optional) — filter by group
- `tagIds` (int[], optional) — filter by tags - `tagIds` (int[], optional) — filter by tags
- `archived` (bool, optional, default: false) - `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 }` - **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` #### `get-task`
- **Description**: Get full task details - **Description**: Get full task details
- **Parameters**: `id` (int, required) - **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 - **Implementation**: `TaskRepository::find($id)` with eager loading
#### `create-task` #### `create-task`
@@ -138,7 +177,7 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses
- `groupId` (int, optional) - `groupId` (int, optional)
- `tagIds` (int[], optional) - `tagIds` (int[], optional)
- **Returns**: Created task with auto-generated number - **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` #### `update-task`
- **Description**: Update an existing task (partial update, only provided fields are changed) - **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) ### TaskMeta Tools (Read-Only)
Statuses, priorities, efforts, and tags are **global** (shared across all projects). Groups are **per-project**.
#### `list-statuses` #### `list-statuses`
- **Description**: List all task statuses (needed to create/update tasks) - **Description**: List all task statuses (needed to create/update tasks)
- **Returns**: Array of `{ id, label, color, position, isFinal }` - **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'])` - **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])`
#### `list-groups` #### `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) - **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false)
- **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }` - **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }`
- **Implementation**: `TaskGroupRepository` with optional project filter - **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**: - **Parameters**:
- `userId` (int, optional) - `userId` (int, optional)
- `projectId` (int, optional) - `projectId` (int, optional)
- `taskId` (int, optional)
- `startDate` (string, optional, format: YYYY-MM-DD) - `startDate` (string, optional, format: YYYY-MM-DD)
- `endDate` (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` - **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt`
#### `create-time-entry` #### `create-time-entry`
- **Description**: Create a time entry - **Description**: Create a time entry
- **Parameters**: - **Parameters**:
- `title` (string, required) - `userId` (int, required)
- `startedAt` (string, required, ISO 8601) - `startedAt` (string, required, ISO 8601)
- `title` (string, optional)
- `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer) - `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer)
- `projectId` (int, optional) - `projectId` (int, optional)
- `taskId` (int, optional) - `taskId` (int, optional)
- `tagIds` (int[], optional) - `tagIds` (int[], optional)
- `description` (string, optional) - `description` (string, optional)
- `userId` (int, required)
- **Returns**: Created time entry - **Returns**: Created time entry
- **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null. - **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null.
#### `update-time-entry` #### `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**: - **Parameters**:
- `id` (int, required) - `id` (int, required)
- `title` (string, optional) - `title` (string, optional)
- `startedAt` (string, optional, ISO 8601)
- `stoppedAt` (string, optional, ISO 8601) - `stoppedAt` (string, optional, ISO 8601)
- `projectId` (int, optional) - `projectId` (int, optional)
- `taskId` (int, optional) - `taskId` (int, optional)
- `tagIds` (int[], optional) - `tagIds` (int[], optional)
- `description` (string, optional) - `description` (string, optional)
- **Returns**: Updated time entry - **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 - **Implementation**: Find entry, apply changes, flush
#### `delete-time-entry` #### `delete-time-entry`
@@ -243,6 +289,7 @@ All tools return JSON strings. For consistency:
- **Get/Create/Update tools**: Return a single JSON object - **Get/Create/Update tools**: Return a single JSON object
- **Delete tools**: Return `{ success: true, message: "..." }` - **Delete tools**: Return `{ success: true, message: "..." }`
- **Errors**: Throw exceptions (the MCP bundle handles error responses) - **Errors**: Throw exceptions (the MCP bundle handles error responses)
- **Duration**: Computed field (minutes), `null` for active timers
Example tool implementation pattern: Example tool implementation pattern:
@@ -272,15 +319,22 @@ class ListTasksTool
?int $groupId = null, ?int $groupId = null,
?array $tagIds = null, ?array $tagIds = null,
bool $archived = false, bool $archived = false,
int $limit = 100,
): string { ): string {
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t') $qb = $this->taskRepository->createQueryBuilder('t')
->leftJoin('t.status', 's')->addSelect('s') ->leftJoin('t.status', 's')->addSelect('s')
->leftJoin('t.priority', 'p')->addSelect('p') ->leftJoin('t.priority', 'p')->addSelect('p')
->leftJoin('t.assignee', 'a')->addSelect('a') ->leftJoin('t.assignee', 'a')->addSelect('a')
->leftJoin('t.project', 'pr')->addSelect('pr') ->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') ->where('t.archived = :archived')
->setParameter('archived', $archived) ->setParameter('archived', $archived)
->orderBy('t.id', 'DESC'); ->orderBy('t.id', 'DESC')
->setMaxResults($limit);
if ($projectId !== null) { if ($projectId !== null) {
$qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId);
@@ -315,20 +369,34 @@ class ListTasksTool
'status' => $task->getStatus() ? [ 'status' => $task->getStatus() ? [
'id' => $task->getStatus()->getId(), 'id' => $task->getStatus()->getId(),
'label' => $task->getStatus()->getLabel(), 'label' => $task->getStatus()->getLabel(),
'color' => $task->getStatus()->getColor(),
] : null, ] : null,
'priority' => $task->getPriority() ? [ 'priority' => $task->getPriority() ? [
'id' => $task->getPriority()->getId(), 'id' => $task->getPriority()->getId(),
'label' => $task->getPriority()->getLabel(), 'label' => $task->getPriority()->getLabel(),
'color' => $task->getPriority()->getColor(),
] : null, ] : null,
'assignee' => $task->getAssignee() ? [ 'assignee' => $task->getAssignee() ? [
'id' => $task->getAssignee()->getId(), 'id' => $task->getAssignee()->getId(),
'username' => $task->getAssignee()->getUsername(), 'username' => $task->getAssignee()->getUsername(),
] : null, ] : 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' => [ 'project' => [
'id' => $task->getProject()->getId(), 'id' => $task->getProject()->getId(),
'code' => $task->getProject()->getCode(), 'code' => $task->getProject()->getCode(),
'name' => $task->getProject()->getName(), 'name' => $task->getProject()->getName(),
], ],
'tags' => $task->getTags()->map(fn($t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray(),
'archived' => $task->isArchived(), 'archived' => $task->isArchived(),
], array_values($tasks))); ], array_values($tasks)));
} }
@@ -339,18 +407,20 @@ class ListTasksTool
1. `composer require symfony/mcp-bundle` (inside Docker container) 1. `composer require symfony/mcp-bundle` (inside Docker container)
2. Create `config/packages/mcp.yaml` with STDIO transport 2. Create `config/packages/mcp.yaml` with STDIO transport
3. Add MCP route: `config/routes/mcp.yaml` 3. Create tool classes in `src/Mcp/Tool/`
4. Create tool classes in `src/Mcp/Tool/` 4. Test with `php bin/console mcp:server` (STDIO)
5. Test with `php bin/console mcp:server` (STDIO) 5. Configure Claude Code settings to point to the MCP server
6. 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) ## Phase 2 (Future)
When ready for remote clients: When ready for remote clients:
1. Enable HTTP transport on `/_mcp` 1. Enable HTTP transport on `/_mcp`
2. Add `apiToken` field on `User` entity + migration 2. Add MCP route: `config/routes/mcp.yaml`
3. Create `ApiTokenAuthenticator` (Symfony custom authenticator) 3. Add `apiToken` field on `User` entity + migration
4. Add firewall rule for `/_mcp` path 4. Create `ApiTokenAuthenticator` (Symfony custom authenticator)
5. Set up Cloudflare Tunnel for external access 5. Add firewall rule for `/_mcp` path
6. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token 6. Set up Cloudflare Tunnel for external access
7. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token