Durch die GUID namen kann ein User die Worktrees keinem Task zuordnen, und in Rider Müllen mir die Worktrees die BRanches voll. Also sollten wir einfach den Task titel oder Nummer stattdessen nehmen ClaudeDo-Task: 63f08d944d3341c081267158ef4bbb3e
18 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. Task worktree branches and folders are claudedo/{number}-{title-slug};
otherwise 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,
VerifyMerges, 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 |
ListHandlerMcpTools |
StartListHandler |
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. skipVerify=true(also onMergeTask/ContinueMerge) is batch mode: the merge lands but the list's verify command is deferred and the task staysWaitingForReviewwith mergeStatusmerged_verify_pending. OneVerifyMergescall after the batch runs the verify once and promotes them — see review-merge.md → batch verify.
VerifyMerges — verify_merges(taskIds): runs the list's verify command ONCE (same per-repo
merge gate) and promotes each listed task to Done via ApproveReviewAsync on success. Explicit
ids on purpose — auto-promoting every WaitingForReview task would sweep up the handler's own
task and deliberately parked verify_failed tasks. Eligibility per task: WaitingForReview AND
(worktree Merged OR no worktree); an Active worktree means the branch never landed and is
refused per-entry (the rest still promote). All ids must share one list. verify_failed promotes
nothing; the merges stay in place.
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.
StartListHandler — the worker only broadcasts ListHandlerStartRequested; the connected
ClaudeDo App does the actual work (creates the handler task, opens the triage ConPTY tile). Before
broadcasting it checks ConnectedClientTracker (a WorkerHub.OnConnectedAsync/OnDisconnectedAsync-driven
singleton count) — with zero connected clients the broadcast would reach nobody, so the tool
returns Requested: false plus a Reason instead of claiming success. Does not queue or retry the
request for a later connect.
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.