14 KiB
External MCP tool surface
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
f3e1641(2026-08-06). Drift check:git log --oneline f3e1641..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.
Hard conventions (enforced by tests)
- 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. - 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 —
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, GetTaskStatusValues, ReviewTask, RunTaskNow, ContinueTask,
CancelTask, DeleteTask.
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) or taskFull (full), never both.
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.