add_task and batch_add_tasks now report up to 3 open (non-terminal)
tasks in the same list with a strongly overlapping title, so a
parallel agent can notice and mention a likely duplicate instead of
silently creating one. The task is always created regardless. Uses a
cheap normalized-word overlap heuristic (no embeddings/LLM call),
robust to German umlaut/digraph spelling variants. Breaking change:
AddTask now returns AddTaskResult { task, possibleDuplicates } instead
of a bare TaskRefDto; BatchAddTaskResult gained a PossibleDuplicates
field.
11 KiB
External MCP tool surface
Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit
7cfe280(2026-08-06). Drift check:git log --oneline 7cfe280..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).
- 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
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.
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. Unlike TaskMergeService.PreviewAsync's
silent "unavailable", this throws a clear error when the task has no worktree, the
worktree isn't Active, or the list's working dir is missing.
PreviewMergeSet — same preview for a batch, 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.
GetTaskLog — latest run's log, tail-capped at 256 KB.
WaitForTaskChange(taskIds, timeoutSeconds = 60) — 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.- 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.