# External MCP — CRUD Extensions **Date:** 2026-04-25 **Status:** Approved ## Goal Give a normal (non-planning) Claude CLI session full control over the ClaudeDo task inbox via the existing always-on `ExternalMcpService`. Primary use case: when a chat session produces scope-creep work, Claude can spin up a fully-formed task — title, description, tags (including the `agent` tag for auto-execution) — without leaving the session. The work is purely additive: the `ExternalMcpService` endpoint is already wired, authenticated by the optional `X-ClaudeDo-Key` header, and exposes `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTaskStatus`, `RunTaskNow`, `CancelTask`. Missing for "full CRUD" are tag handling, content updates, deletion, and tag discovery. ## Scope | Tool | Status | Notes | |---|---|---| | `ListTaskLists` | exists | unchanged | | `ListTasks` | exists | unchanged | | `GetTask` | exists | unchanged | | `AddTask` | extend | add optional `tags` parameter | | `UpdateTaskStatus` | exists | unchanged (Manual ↔ Queued) | | `RunTaskNow` | exists | unchanged | | `CancelTask` | exists | unchanged | | `UpdateTask` | new | patch title/description/commitType/tags | | `DeleteTask` | new | delete a task (cascades) | | `SetTaskTags` | new | replace the full tag set on a task | | `ListTags` | new | enumerate all known tag names | Out of scope: - List CRUD (creating/renaming/deleting lists) — out of scope for this iteration; UI remains the source of truth for list management. - ListConfig / agent settings overrides — handled by the UI, not surfaced via MCP here. - Tag CRUD beyond auto-creation during `AddTask` / `UpdateTask` / `SetTaskTags`. There is no `DeleteTag` tool; tag rows live as long as some task references them. ## Authentication No change. The endpoint continues to be gated by `ExternalMcpAuthMiddleware` — if `WorkerConfig.ExternalMcpApiKey` is set, callers must include `X-ClaudeDo-Key`; otherwise the loopback-only worker is open to local processes. ## Tool specifications ### `AddTask` (extended) ``` AddTask( listId: string, title: string, description: string?, createdBy: string, queueImmediately: bool, tags: string[]?, cancellationToken) -> TaskDto ``` Behavior: - Existing behavior preserved. New `tags` parameter, when non-null, attaches the named tags to the new task. - Tag names are matched case-insensitively against existing rows; missing tag rows are auto-created (mirrors `TaskRepository.CreateChildAsync`). - Empty/whitespace tag names are skipped; duplicates are deduplicated. - `tags` is the LAST parameter before `CancellationToken` so existing positional callers are unaffected (CancellationToken is bound by name in MCP runtime; defensive — see Migration). ### `UpdateTask` (new) ``` UpdateTask( taskId: string, title: string?, description: string?, commitType: string?, tags: string[]?, cancellationToken) -> TaskDto ``` Behavior: - Loads the task; throws `InvalidOperationException` if not found. - **Refuses if status is `Running`** — protects in-flight worktrees and the streaming log. - Does NOT change status (use `UpdateTaskStatus`) and does NOT change `createdBy`, `listId`, or `parentTaskId` (audit + structural fields, immutable here). - For each non-null parameter, applies the update. Null means "leave unchanged". - `tags` semantics: full replacement of the tag set (same as `SetTaskTags`). Auto-creates missing tag rows. - Broadcasts `TaskUpdated` on the SignalR hub on success. ### `DeleteTask` (new) ``` DeleteTask(taskId: string, cancellationToken) -> void ``` Behavior: - Loads the task; throws if not found. - **Refuses if status is `Running`** — caller must `CancelTask` first. - Calls `TaskRepository.DeleteAsync` (FK cascades remove `task_tags`, `worktrees`, `task_runs`, `subtasks`). - Broadcasts `TaskUpdated(taskId)` so UIs drop the row. ### `SetTaskTags` (new) ``` SetTaskTags(taskId: string, tags: string[], cancellationToken) -> TaskDto ``` Behavior: - Convenience wrapper for "I just want to (re)set tags". Equivalent to `UpdateTask(taskId, null, null, null, tags)`. - Same validation: refuses if `Running`. - Returns the updated `TaskDto` (with status; tags are not included in `TaskDto` today — see Open Decisions). ### `ListTags` (new) ``` ListTags(cancellationToken) -> { Id: long, Name: string }[] ``` Behavior: - Returns every row in the `tags` table. No filter, no pagination — the table is small (seed values + user-defined). - Lets Claude discover existing tag names (`agent`, `manual`, plus any user-defined) before tagging, avoiding duplicates that differ only by case/whitespace. ## Repository changes `src/ClaudeDo.Data/Repositories/TaskRepository.cs`: - Add `public Task SetTagsAsync(string taskId, IReadOnlyList tagNames, CancellationToken ct = default)` — replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already inside `CreateChildAsync` and the new `UpdateChildAsync` from the planning-MCP work; consider extracting a private helper `ApplyTagsAsync(TaskEntity, IReadOnlyList, CancellationToken)` shared by both. `src/ClaudeDo.Data/Repositories/TagRepository.cs`: - Add `public Task> GetAllAsync(CancellationToken ct = default)` if it does not already exist. (Matches `ListRepository.GetAllAsync` style.) No new tables, no migrations. ## Service changes `src/ClaudeDo.Worker/External/ExternalMcpService.cs`: - Add `TagRepository` to the constructor (DI registration is already in place since the planning service uses it). - Extend `AddTask` signature with `IReadOnlyList? tags` and apply via the repository. - Add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` methods, each annotated `[McpServerTool, Description("…")]`. - Each new mutating tool calls `_broadcaster.TaskUpdated(taskId)` on success (matches existing pattern in this file). DI: `ExternalMcpService` is already registered. If `TagRepository` is not already registered (it is — used by `ListRepository`), no change. If a constructor parameter is added, `Program.cs` does not need changes because services are scoped/transient. ## Error handling All errors raised as `InvalidOperationException` with a human-readable message — matches the existing pattern in `ExternalMcpService` and `PlanningMcpService`. The MCP SDK serializes these to the JSON-RPC error channel; Claude sees the message text directly. Specific cases: - Task not found → `"Task {id} not found."` - Running-task guard → `"Cannot {update|delete} a running task. Cancel it first."` - Unknown status (in `UpdateTaskStatus`, unchanged) → `"Unknown status '{x}'."` ## Testing Add `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (or extend if it exists) with: | Test | Asserts | |---|---| | `AddTask_WithTags_AttachesTags` | `tags` param creates and attaches tag rows | | `AddTask_WithUnknownTag_AutoCreatesTagRow` | new tag name produces a row in `tags` table | | `UpdateTask_PatchesNonNullFields` | only non-null fields change | | `UpdateTask_OnRunning_Throws` | `InvalidOperationException` | | `UpdateTask_BroadcastsTaskUpdated` | hub broadcast received | | `UpdateTask_TagsReplaceFullSet` | passing tags=[…] replaces existing tags wholesale | | `DeleteTask_RemovesTaskAndTagJoins` | task and `task_tags` rows gone | | `DeleteTask_OnRunning_Throws` | `InvalidOperationException` | | `SetTaskTags_ReplacesAndBroadcasts` | replacement semantics + broadcast | | `ListTags_ReturnsSeedAndCustomTags` | `agent` + `manual` + any user-defined | Existing test infrastructure (`DbFixture`, `FakeHubContext`) is reused. No new fakes required. **Caveat:** the test assembly currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (missing constructor argument in `WorkerHub`/`TaskRunner` test instantiations). Tests will pass only after that work lands; do not block this design on it. ## Open decisions (defaults chosen, easy to flip) 1. **`TaskDto` does not currently include tags.** For consistency, the spec keeps `TaskDto` as-is and ships a separate `ListTags` tool. If preferred, we could add `Tags: string[]` to `TaskDto` so every tool response includes them — small DB cost (one extra `SelectMany`), one struct field added. Default: leave `TaskDto` alone, defer. 2. **Per-tag `AddTaskTag` / `RemoveTaskTag` micro-tools.** Skipped — `SetTaskTags` covers the use case, and it's idempotent. Add later if granular ops are wanted. 3. **List CRUD via MCP.** Out of scope. UI owns lists. ## Migration / compatibility `AddTask` gains an optional parameter. The MCP server SDK sends parameters by name in JSON-RPC `params`, so existing clients that omit `tags` continue to work without code changes. No version bump required.