Files
Lesstime/docs/superpowers/specs/2026-03-15-mcp-server-design.md
2026-03-15 19:17:44 +01:00

19 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).

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

# 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):

location /_mcp {
    try_files $uri /index.php$is_args$args;
}

Claude Code Configuration

Option A — Local (STDIO, same machine):

{
  "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):

{
  "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

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