496 lines
19 KiB
Markdown
496 lines
19 KiB
Markdown
# 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).
|
|
|
|
Both transports are implemented together:
|
|
- **STDIO**: Claude Code on the same machine (local dev, `php bin/console mcp:server`)
|
|
- **HTTP**: Claude Code or any MCP client on the LAN (`http://<server-ip>:8082/_mcp`), secured by API token
|
|
|
|
Future: Cloudflare Tunnel for internet-facing access (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
|
|
│ │ └── UpdateProjectTool.php
|
|
│ ├── Task/
|
|
│ │ ├── ListTasksTool.php
|
|
│ │ ├── GetTaskTool.php
|
|
│ │ ├── CreateTaskTool.php
|
|
│ │ ├── UpdateTaskTool.php
|
|
│ │ └── DeleteTaskTool.php
|
|
│ ├── TaskMeta/
|
|
│ │ ├── ListStatusesTool.php
|
|
│ │ ├── ListPrioritiesTool.php
|
|
│ │ ├── ListEffortsTool.php
|
|
│ │ ├── ListTagsTool.php
|
|
│ │ ├── ListGroupsTool.php
|
|
│ │ ├── CreateGroupTool.php
|
|
│ │ └── UpdateGroupTool.php
|
|
│ ├── TimeEntry/
|
|
│ │ ├── ListTimeEntriesTool.php
|
|
│ │ ├── CreateTimeEntryTool.php
|
|
│ │ ├── UpdateTimeEntryTool.php
|
|
│ │ └── DeleteTimeEntryTool.php
|
|
│ └── Reference/
|
|
│ ├── ListUsersTool.php
|
|
│ └── ListClientsTool.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.
|
|
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: true
|
|
|
|
http:
|
|
path: /_mcp
|
|
session:
|
|
store: file
|
|
directory: '%kernel.cache_dir%/mcp-sessions'
|
|
ttl: 3600
|
|
```
|
|
|
|
### Nginx Configuration
|
|
|
|
Add a location block to pass `/_mcp` requests to Symfony (same pattern as `/api`):
|
|
|
|
```nginx
|
|
location /_mcp {
|
|
try_files $uri /index.php$is_args$args;
|
|
}
|
|
```
|
|
|
|
### Claude Code Configuration
|
|
|
|
**Option A — Local (STDIO, same machine):**
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"lesstime": {
|
|
"command": "docker",
|
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"],
|
|
"cwd": "/home/r-dev/Lesstime"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Option B — Network (HTTP, another machine on LAN):**
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"lesstime": {
|
|
"type": "url",
|
|
"url": "http://192.168.x.x:8082/_mcp",
|
|
"headers": {
|
|
"Authorization": "Bearer <api-token>"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Security Model
|
|
|
|
**STDIO transport**: No authentication. The console command runs locally with full privileges (equivalent to ROLE_ADMIN). Only the local developer has access.
|
|
|
|
**HTTP transport**: Secured by API token. A new `apiToken` field on the `User` entity stores a unique token per user. A custom Symfony authenticator (`ApiTokenAuthenticator`) checks the `Authorization: Bearer <token>` header on `/_mcp` requests and authenticates as the corresponding user.
|
|
|
|
#### API Token Implementation
|
|
|
|
1. **Entity change**: Add `apiToken` (string, unique, nullable) to `User` + Doctrine migration
|
|
2. **Authenticator**: `src/Security/ApiTokenAuthenticator.php` — a Symfony custom authenticator that:
|
|
- Extracts the token from the `Authorization` header
|
|
- Looks up the user by `apiToken`
|
|
- Returns 401 if token missing/invalid
|
|
3. **Firewall**: New firewall entry in `config/packages/security.yaml` for `/_mcp` path, before the main `api` firewall
|
|
4. **Token generation**: A console command `app:generate-api-token <username>` to generate/regenerate tokens
|
|
5. **Fixtures**: Add an API token to the admin fixture user for dev/testing
|
|
|
|
## 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`
|
|
- **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`
|
|
|
|
#### `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. Returns max 100 results, use filters to narrow down.
|
|
- **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)
|
|
- `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, 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, createdAt, uploadedBy: { id, username } }], 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, 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)
|
|
- **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
|
|
|
|
Statuses, priorities, efforts, and tags are **global** (shared across all projects, read-only via MCP). Groups are **per-project** (read/create/update).
|
|
|
|
#### `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. 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
|
|
|
|
#### `create-group`
|
|
- **Description**: Create a new task group for a project
|
|
- **Parameters**:
|
|
- `projectId` (int, required)
|
|
- `title` (string, required)
|
|
- `description` (string, optional)
|
|
- `color` (string, optional, default: #222783)
|
|
- **Returns**: Created group object
|
|
- **Implementation**: Create `TaskGroup` entity, set project relation, persist
|
|
|
|
#### `update-group`
|
|
- **Description**: Update an existing task group (partial update)
|
|
- **Parameters**:
|
|
- `id` (int, required)
|
|
- `title` (string, optional)
|
|
- `description` (string, optional)
|
|
- `color` (string, optional)
|
|
- `archived` (bool, optional)
|
|
- **Returns**: Updated group object
|
|
- **Implementation**: Find group, apply changes, flush
|
|
|
|
### TimeEntry Tools
|
|
|
|
#### `list-time-entries`
|
|
- **Description**: List time entries with filters
|
|
- **Parameters**:
|
|
- `userId` (int, optional)
|
|
- `projectId` (int, optional)
|
|
- `taskId` (int, optional)
|
|
- `startDate` (string, optional, format: YYYY-MM-DD)
|
|
- `endDate` (string, optional, format: YYYY-MM-DD)
|
|
- `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**:
|
|
- `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)
|
|
- **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, 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`
|
|
- **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)
|
|
- **Duration**: Computed field (minutes), `null` for active timers
|
|
|
|
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,
|
|
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')
|
|
->setMaxResults($limit);
|
|
|
|
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(),
|
|
'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)));
|
|
}
|
|
}
|
|
```
|
|
|
|
## Installation Steps
|
|
|
|
1. `composer require symfony/mcp-bundle` (inside Docker container)
|
|
2. Create `config/packages/mcp.yaml` with STDIO + HTTP transports
|
|
3. Add MCP route: `config/routes/mcp.yaml`
|
|
4. Add Nginx location block for `/_mcp`
|
|
5. Add `apiToken` field to `User` entity + migration
|
|
6. Create `ApiTokenAuthenticator` + security firewall for `/_mcp`
|
|
7. Create `app:generate-api-token` console command
|
|
8. Update fixtures with API token for admin user
|
|
9. Create tool classes in `src/Mcp/Tool/`
|
|
10. Test STDIO: `php bin/console mcp:server`
|
|
11. Test HTTP: `curl -H "Authorization: Bearer <token>" http://localhost:8082/_mcp`
|
|
12. Configure Claude Code settings (STDIO local or HTTP network)
|
|
|
|
## Future
|
|
|
|
When ready for internet-facing access:
|
|
|
|
1. Set up Cloudflare Tunnel for external access
|
|
2. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token
|