Files
ClaudeDo/docs/explore-notes/external-mcp.md
T

11 KiB

External MCP tool surface

Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit bdee731 (2026-08-05). Drift check: git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/External Stable 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)

  1. 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. ExternalMcpToolSchemaTests guards this by reflection.
  2. No tool returns bare Task or 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 / SetTaskConfigResult additionally 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 / Available flag alongside the nullable payload (TaskConfigResult, BatchGetTaskResult, TaskLogResult).
  3. ExternalMcpExceptionFilter.Wrap is registered as a call-tool filter so InvalidOperationException / ArgumentException messages survive as McpException — otherwise the SDK's catch-all replaces any non-McpException with 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.

  • Cancelled goes through TaskStateService.CancelAsync(..., allowFromIdle: true) — the only caller that opts into cancelling from Idle. PlanningChainCoordinator relies on Idle staying a no-op there by default, because a child parked back to Idle mid-chain is a manual opt-out signal.
  • Done goes through TaskStateService.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 skip review_task's merge.

ReviewTaskapprove / 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 Done with a Merged worktree carrying a recorded WorktreeEntity.MergeCommit. A task merged before that field existed has none and is refused rather than guessed via git log.
  • On success the task returns to WaitingForReview and the worktree moves to Kept — not Active (its directory/branch are typically already gone from the original merge's cleanup) and not Merged/Discarded (WorktreeMaintenanceService sweeps 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 DbContext per check, 500 ms delay, no held connection, no busy loop) rather than hooking HubBroadcaster — deliberately isolated so it can't regress the existing broadcast callers.
  • timeoutSeconds is clamped server-side to TaskWaitMcpTools.MaxTimeoutSeconds (900 s), comfortably under the MCP_TOOL_TIMEOUT (930 s) every ClaudeDo-owned claude launcher sets — ClaudeProcess for headless queue runs, InteractiveLaunchSpecService for every embedded ConPTY session (list handler, planning, interactive resume) — so the tool reports timedOut: true instead of racing the client's own abort. A caller running claude with a different (or default: 60 s) MCP_TOOL_TIMEOUT will 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_task in 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/effectiveSlots reuse QueueService.GetSlotCountsAsync — the same configured-vs-throttled computation QueueService.ExecuteAsync uses each tick (see usage-monitoring for the throttle staging) — so this tool can't drift from the queue's actual refill decision.
  • activeSlots reuses QueueService.GetActive() (already the source for the Hub's GetActive): slot is "queue" for a normal queue slot or "override" for the single run_task_now/continue_task slot.
  • waitingTaskIds is a fresh read-only query mirroring QueuePicker.ClaimNextAsync's eligibility filter and order (Queued, unblocked, non-manual, due, sort_order then created_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.