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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user