Document task-number allocation in Data layer (TaskEntity.Number,
next_task_number counter, invariants, insert paths).
Update Worker docs to clarify TaskIdResolver wiring (#123 → GUID lookup),
Number in MCP payloads, and correct the 'Two hard conventions' statement
(only the first is test-enforced).
Bump external-mcp.md verified-against commit to 38af549 (Slice 4 merge)
and add new sections on task ID resolution and numbering.
Add Slice 4 visual verification items to open.md (row/detail number display,
worker-log messages).
Verified against:
- src/ClaudeDo.Data/TaskNumberAllocator.cs
- src/ClaudeDo.Data/Repositories/TaskRepository.cs (AddAsync line 20, CreateChildAsync line 276)
- src/ClaudeDo.Worker/External/TaskIdResolver.cs
- src/ClaudeDo.Worker/External/ExternalMcpService.cs (TaskDto/TaskRefDto DTOs)
- tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs (only first convention test-enforced)
17 KiB
External MCP tool surface
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
38af549(2026-08-11). Drift check:git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/ExternalStable structure only (no line numbers). See docs/explore-notes/README.md.
Covers src/ClaudeDo.Worker/External/ — the always-on MCP tools ClaudeDo exposes to general
Claude sessions. Registered explicitly in Program.cs's external app via .WithTools<T>().
Server name claudedo, registered globally by the installer's RegisterMcpStep, so callers
need no --mcp-config.
Scope boundary: these tools cover starting and observing sessions plus task/list CRUD
and git/merge operations. They deliberately do not expose multi-turn control, planning
session internals, or app-settings writes. Auth via an optional X-ClaudeDo-Key header.
Task ID resolution and numbering
Every tool parameter accepting a task id — taskId, parentId, taskIds arrays, and similar —
is wired through TaskIdResolver, which resolves:
#123(with or without the#prefix) → look up byTaskEntity.Number- Bare GUID string → use as-is
- Unknown number →
InvalidOperationException(no task with number 123)
This is not ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
number, not a partial GUID. All task-returning tools (GetTask, ListTasks, AddTask,
BatchGetTasks, etc.) stamp the resolved task's Number in the DTO — both TaskDto and the
lean TaskRefDto carry an int Number field. Branch names and worktree paths (claudedo/{id})
continue to use the GUID; the number is a display alias, never the identity.
Every tool description carries a shared boilerplate clause (defined in McpToolDocs.TaskNumberHint)
instructing the agent to refer to tasks as #<number> when reporting results to the user —
without this the agent sees the number in every payload but never learns to speak it.
Conventions
Test-enforced
- Every optional/filter parameter needs a C# default value (e.g.
string? status = null). The MCP schema only marks a parameter optional when it has one — nullability alone does not do it.ExternalMcpToolSchemaTestsguards this by reflection.
Not test-enforced, but strongly observed
-
No tool returns bare
Taskor a nullable payload directly. An MCP client cannot tell an empty/omitted response apart from a dropped one.- Write tools return a small confirmation record —
{ ok/deleted/removed/reset/started: true, <id>, ... }(DeleteListResult,RunTaskNowResult,ResetFailedTaskResult,RemoveAttachmentResult;SetListConfigResult/SetTaskConfigResultadditionally echo the resulting config so the caller can see which fields were set vs. cleared to null). - Read tools that may have nothing to return use an explicit
Found/Availableflag alongside the nullable payload (TaskConfigResult,BatchGetTaskResult,TaskLogResult). - The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
get":
ListTasks/BatchGetTaskstakeincludeDescription(defaultfalse) and returnListTasksResult/BatchGetTaskResult, where exactly one of the lean (TaskRefDto) and full (TaskDto, incl. Description/Result) fields is populated per the flag — keeps a list of verbosely-described tasks from blowing past the response size limit by default.
- Write tools return a small confirmation record —
-
Description style is documented in
McpToolDocs(same folder) and shared boilerplate lives there asconststrings. Rules: the first sentence says what the tool does and when to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind return-shape prose); parameters are documented with[Description]on the parameter, not in the tool description; result fields appear only where the caller must branch on them before calling (isEmpty,truncated,conflicts,available); no design rationale or "since this feature was introduced" history. -
ExternalMcpExceptionFilter.Wrapis registered as a call-tool filter soInvalidOperationException/ArgumentExceptionmessages survive asMcpException— otherwise the SDK's catch-all replaces any non-McpExceptionwith a generic "An error occurred invoking 'X'."
Tool classes
ExternalMcpService — task CRUD, execution, git
Task: ListTaskLists, ListTasks, GetTask, AddTask, AddSubtask, UpdateTask,
UpdateTaskStatus, ReviewTask, RunTaskNow, ContinueTask, CancelTask, DeleteTask.
(GetTaskStatusValues was removed — a whole tool entry for static reference text. GetTask's
description is now the canonical place for what each status means.)
Worktree/git: GetTaskWorktree, GetTaskDiff, MergeTask, ContinueMerge, AbortMerge,
PreviewMerge, PreviewMergeSet, RevertMerge, ListWorktrees, CleanupTaskWorktree.
Daily prep: GetDailyPrepCandidates, SetMyDay.
Other classes
| Class | Tools |
|---|---|
BatchMcpTools |
BatchGetTasks, BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks, BatchSetMyDay, BatchCleanupTaskWorktrees |
ListMcpTools |
CreateList, UpdateList, DeleteList |
ConfigMcpTools |
GetListConfig, SetListConfig, GetTaskConfig, SetTaskConfig, GetEffectiveRunConfig |
RunHistoryMcpTools |
ListRuns, GetRun, GetTaskLog |
AgentMcpTools |
ListAgents |
LifecycleMcpTools |
ResetFailedTask |
AppSettingsMcpTools |
GetAppSettings (read-only) |
TaskWaitMcpTools |
WaitForTaskChange |
QueueStateMcpTools |
GetQueueState |
AttachmentMcpTools |
AddTaskAttachment, ListTaskAttachments, RemoveTaskAttachment |
Per-tool behaviour worth knowing
ListTasks — includeDescription=false (default) returns lean TaskRefDto references in
tasks (tasksFull null); includeDescription=true returns full TaskDtos (incl.
Description/Result) in tasksFull instead (tasks null). Filtering by createdBy/status
happens before the lean/full projection either way.
UpdateTaskStatus accepts Idle / Queued / Cancelled / Done only.
Cancelledgoes throughTaskStateService.CancelAsync(..., allowFromIdle: true)— the only caller that opts into cancelling fromIdle.PlanningChainCoordinatorrelies onIdlestaying a no-op there by default, because a child parked back toIdlemid-chain is a manual opt-out signal.Donegoes throughTaskStateService.ForceSetStatusAsync(the same unconditional write the UI's "set status freely" affordance uses) but is refused for a task with an active worktree, since that would skipreview_task's merge.
AddTask — always creates the task; also returns possibleDuplicates (up to 3, id/title/status
only, no descriptions) — open (non-terminal) tasks in the same list whose normalized title
overlaps strongly with the new one. Cheap word-overlap heuristic (ExternalMcpService's
FindPossibleDuplicatesAsync/NormalizeTitleWords), no embeddings/LLM call, no blocking —
the caller just gets a heads-up to relay. BatchAddTasks carries the same field per item.
ReviewTask — approve / reject_rerun / reject_park / cancel for a
WaitingForReview task. Approve is review+merge exactly like the hub's ApproveReview: unit
merge for parents, worktree merge into optional targetBranch for childless tasks. Conflicts
are reported in ReviewTaskResult.
- A parent's approve also returns
emptyChildren: theDonechildren about to be unit-merged whose own review range contributed nothing (computed the same way asPreviewMerge'sisEmpty, before the merge starts so it reflects what's about to be approved). Surfaces a child that reportedCLAUDEDO_BLOCKEDand committed no code — previously that child reachedDoneand merged silently withchangedFileCount: 0, indistinguishable from a small-but-real change.TaskRefDto.roadblockCount(on every task-returning tool, stamped byTaskRunnerfromresult.Blocks.Count) is the MCP-visible signal for why a child is empty.
PreviewMerge — non-destructive git merge-tree --write-tree mergeability check for one
task's worktree branch against targetBranch (default: the repo's current branch). Returns
status / conflictFiles / changedFileCount / behind / isEmpty. Unlike
TaskMergeService.PreviewAsync's silent "unavailable", this throws a clear error when the
task has neither an active worktree nor a handler commit range, or the list's working dir is
missing.
isEmpty= the review range contributed nothing — zero files changed against the worktree's base commit, or (for a worktree-less list-handler host task)HandlerBaseCommit == HandlerHeadCommit. Distinguishes a genuinely empty branch from one that merely made a small change (changedFileCount: 0alone reads as "tiny", not "nothing to review") — the gap that let two blocked planning children reachDonewith unmerged empty branches unnoticed.- A worktree-less handler task has no separate branch to
merge-tree-preview (its commits already sit inlist.WorkingDir) —PreviewMergeCoreAsyncfalls back to a syntheticcleanpreview over its ownHandlerBaseCommit..HandlerHeadCommitdiff-stat instead of throwing "has no worktree".
PreviewMergeSet — same preview for a batch (each entry also carries isEmpty), plus a
file→tasks overlap report built from each task's own diff-stat. ⚠️ That overlap report is a
same-file-name hint only — it is blind to cross-file collisions (e.g. the CS0103 case that
motivated it). A task that fails to preview gets error set and is excluded from the overlap
instead of aborting the batch.
RevertMerge — undoes a previously merged task's merge commit on targetBranch via
git revert -m 1. Always a new commit, never a reset/rewrite, because the target working
directory is shared with other concurrent sessions.
- Requires the task to be
Donewith aMergedworktree carrying a recordedWorktreeEntity.MergeCommit. A task merged before that field existed has none and is refused rather than guessed viagit log. - On success the task returns to
WaitingForReviewand the worktree moves toKept— notActive(its directory/branch are typically already gone from the original merge's cleanup) and notMerged/Discarded(WorktreeMaintenanceServicesweeps those). - A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
BatchMcpTools — best-effort loops over the ExternalMcpService single-entity methods.
Sequential, because the scoped DbContext is not thread-safe. Merge/review stay
single-task. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a
failing item never aborts the rest — and rejects batches over 100 items.
BatchGetTasks mirrors ListTasks's includeDescription flag (default false): a found item's
BatchGetTaskResult carries task (lean TaskRefDto) or taskFull, never both. taskFull is
BatchTaskDetailDto — a batch-only shape, not TaskDto (get_task is untouched) — whose
Description/Result are cut to descriptionMaxChars (default 1500, was unlimited) with
*Truncated/*FullLength flagging it, and which fields (an optional name allow-list, e.g.
['title','roadblockText']) can narrow further; unrequested fields come back null.
roadblockText pulls just the bullet lines after TaskRunner.ComposeReviewResult's roadblock
marker out of Result, without needing the rest of it. The whole per-call response is also
capped (BatchMcpTools.MaxResponseChars) — over that, the call throws naming which parameter to
adjust instead of shipping an oversized payload (the incident that prompted this: 8 tasks'
full Description/Result serialized to a single 51k-char line).
GetTaskLog — latest run's log, tail-capped at 256 KB.
WaitForTaskChange(taskIds, timeoutSeconds = 60, treatWaitingForChildrenAsBusy = false) —
blocks until any given task leaves Queued/Running, or times out. Returns immediately for a
task already outside those two (unknown ids reported as status "NotFound", also immediate).
- Implemented as an async DB poll (short-lived
DbContextper check, 500 ms delay, no held connection, no busy loop) rather than hookingHubBroadcaster— deliberately isolated so it can't regress the existing broadcast callers. timeoutSecondsis clamped server-side toTaskWaitMcpTools.MaxTimeoutSeconds(900 s), comfortably under theMCP_TOOL_TIMEOUT(930 s) every ClaudeDo-owned claude launcher sets —ClaudeProcessfor headless queue runs,InteractiveLaunchSpecServicefor every embedded ConPTY session (list handler, planning, interactive resume) — so the tool reportstimedOut: trueinstead of racing the client's own abort. A caller running claude with a different (or default: 60 s)MCP_TOOL_TIMEOUTwill still see its own client-side timeout fire first; the server has no way to detect or compensate for that.treatWaitingForChildrenAsBusypitfall (defaultfalse, backward-compatible): a planning parent with children goesRunning→WaitingForChildrenwhile children are still working, and by default that already counts as "changed" (it's outsideQueued/Running) — so waiting on a parent returns immediately even though the unit isn't done. Set the flag to keep polling throughWaitingForChildren; the call then only reports changed once the parent reachesWaitingForReviewor a terminal status. Does not list or watch the parent's children — callers still need their own ids for that.- Replaced the list handler's old "sleep + poll
get_taskin a loop" Phase 3 instruction.
GetQueueState() — read-only snapshot so a caller doesn't have to infer queue state from
maxParallelExecutions or repeated wait_for_task_change rounds:
{ configuredSlots, effectiveSlots, activeSlots: [{ slot, taskId, startedAt }], waitingTaskIds }.
configuredSlots/effectiveSlotsreuseQueueService.GetSlotCountsAsync— the same configured-vs-throttled computationQueueService.ExecuteAsyncuses each tick (see usage-monitoring for the throttle staging) — so this tool can't drift from the queue's actual refill decision.activeSlotsreusesQueueService.GetActive()(already the source for the Hub'sGetActive):slotis"queue"for a normal queue slot or"override"for the singlerun_task_now/continue_taskslot.waitingTaskIdsis a fresh read-only query mirroringQueuePicker.ClaimNextAsync's eligibility filter and order (Queued, unblocked, non-manual, due,sort_orderthencreated_at) — it does not claim or mutate anything.
AttachmentMcpTools — re-attaching the same fileName overwrites. Add/remove refuse on a
Running task.
GetDailyPrepCandidates — Idle, non-blocked tasks in a git repo not excluded by
AppSettings.ReportExcludedPaths and not already IsMyDay, plus the current Idle MyDay tasks
and maxTasks (= DailyPrepMaxTasks). Repo-exclusion logic lives in the DailyPrepFilter
helper in the same file.
SetMyDay — sets IsMyDay (+ optional SortOrder). A server-side cap-guard rejects
turning on MyDay beyond DailyPrepMaxTasks open (Idle) MyDay tasks.
GetEffectiveRunConfig — read-only report of what a task will actually run with (model,
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
each with its source (task/list/preset/global); max turns additionally reports the raw
requested value and whether it was clamped to AppSettings.MaxTurnsCeiling. Unlike
GetAppSettings/GetTaskConfig (raw, possibly-unused config values), this goes through the same
EffectiveRunConfigResolver.Resolve that TaskRunner itself runs with — see
worker-task-pipeline's model/effort/max-turns section — so it can't
drift from the real run. Reads (not writes) AppSettingsRepository.GetAsync, which backfills
model_presets on first read after a null column; that backfill is pre-existing shared behavior,
not a new side effect introduced by this tool.
Model / max-turns on task creation
Task-generating tools (AddTask, planning CreateChildTask, SuggestImprovement) accept an
optional model, alias-validated via ModelRegistry.NormalizeAlias (haiku/sonnet/opus,
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
planning/system/improvement prompts instruct it to do so, using
ModelRegistry.ByCostAscending as the cost order.
Planning's CreateChildTask additionally accepts an optional maxTurns (positive int;
0/negative rejected with ArgumentException, null = inherit list/global default) so the
planner can raise the turn budget for a subtask it knows will run long.
SuggestImprovement and AddTask do not expose it.