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>
17 KiB
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
│ │ └── UpdateProjectTool.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
│ └── Reference/
│ ├── ListUsersTool.php
│ └── ListClientsTool.php
Configuration
# 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: false # Phase 2
Claude Code Configuration
// .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.
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
- 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
Projectentity, persist viaEntityManager
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 projectstatusId(int, optional) — filter by statusassigneeId(int, optional) — filter by assigneepriorityId(int, optional) — filter by prioritygroupId(int, optional) — filter by grouptagIds(int[], optional) — filter by tagsarchived(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:
TaskRepositorywith QueryBuilder, conditional filters, andsetMaxResults($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
Taskentity, reuseTaskRepository::findMaxNumberByProject()for number generation (same logic asTaskNumberProcessor), 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)
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 } - 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:
TaskGroupRepositorywith optional project filter
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:
durationis computed fromstoppedAt - startedAtin minutes. Returnsnullfor active timers (stoppedAt is null). - Implementation:
TimeEntryRepositorywith QueryBuilder, date range filter onstartedAt
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:
userIdis 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),
nullfor active timers
Example tool implementation pattern:
<?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
composer require symfony/mcp-bundle(inside Docker container)- Create
config/packages/mcp.yamlwith STDIO transport - Create tool classes in
src/Mcp/Tool/ - Test with
php bin/console mcp:server(STDIO) - 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:
- Enable HTTP transport on
/_mcp - Add MCP route:
config/routes/mcp.yaml - Add
apiTokenfield onUserentity + migration - Create
ApiTokenAuthenticator(Symfony custom authenticator) - Add firewall rule for
/_mcppath - Set up Cloudflare Tunnel for external access
- Configure Claude Web / ChatGPT / Codex with the tunnel URL + token