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

14 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
│   ├── 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

# 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

// .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

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