Capture the design and execution plan for the AddTask/UpdateTask/DeleteTask/ SetTaskTags external MCP work that landed in commits 1a74e1c..59dc1e2.
8.7 KiB
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 noDeleteTagtool; 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
tagsparameter, 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.
tagsis the LAST parameter beforeCancellationTokenso 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
InvalidOperationExceptionif not found. - Refuses if status is
Running— protects in-flight worktrees and the streaming log. - Does NOT change status (use
UpdateTaskStatus) and does NOT changecreatedBy,listId, orparentTaskId(audit + structural fields, immutable here). - For each non-null parameter, applies the update. Null means "leave unchanged".
tagssemantics: full replacement of the tag set (same asSetTaskTags). Auto-creates missing tag rows.- Broadcasts
TaskUpdatedon 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 mustCancelTaskfirst. - Calls
TaskRepository.DeleteAsync(FK cascades removetask_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 inTaskDtotoday — see Open Decisions).
ListTags (new)
ListTags(cancellationToken) -> { Id: long, Name: string }[]
Behavior:
- Returns every row in the
tagstable. 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<string> tagNames, CancellationToken ct = default)— replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already insideCreateChildAsyncand the newUpdateChildAsyncfrom the planning-MCP work; consider extracting a private helperApplyTagsAsync(TaskEntity, IReadOnlyList<string>, CancellationToken)shared by both.
src/ClaudeDo.Data/Repositories/TagRepository.cs:
- Add
public Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)if it does not already exist. (MatchesListRepository.GetAllAsyncstyle.)
No new tables, no migrations.
Service changes
src/ClaudeDo.Worker/External/ExternalMcpService.cs:
- Add
TagRepositoryto the constructor (DI registration is already in place since the planning service uses it). - Extend
AddTasksignature withIReadOnlyList<string>? tagsand apply via the repository. - Add
UpdateTask,DeleteTask,SetTaskTags,ListTagsmethods, 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)
TaskDtodoes not currently include tags. For consistency, the spec keepsTaskDtoas-is and ships a separateListTagstool. If preferred, we could addTags: string[]toTaskDtoso every tool response includes them — small DB cost (one extraSelectMany), one struct field added. Default: leaveTaskDtoalone, defer.- Per-tag
AddTaskTag/RemoveTaskTagmicro-tools. Skipped —SetTaskTagscovers the use case, and it's idempotent. Add later if granular ops are wanted. - 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.