docs : add MCP server design spec for Lesstime
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
356
docs/superpowers/specs/2026-03-15-mcp-server-design.md
Normal file
356
docs/superpowers/specs/2026-03-15-mcp-server-design.md
Normal file
@@ -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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state')]
|
||||
public function __invoke(
|
||||
?int $projectId = null,
|
||||
?int $statusId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $priorityId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
bool $archived = false,
|
||||
): string {
|
||||
$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')
|
||||
->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
|
||||
Reference in New Issue
Block a user