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
277 lines
18 KiB
Markdown
277 lines
18 KiB
Markdown
# 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/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.
|
|
|
|
## 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 by `TaskEntity.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
|
|
|
|
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.
|
|
|
|
### Not test-enforced, but strongly observed
|
|
|
|
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`).
|
|
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
|
|
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
|
|
`ListTasksResult`/`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.
|
|
|
|
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
|
|
lives there as `const` strings. 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.
|
|
|
|
4. `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`, `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 `TaskDto`s (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.
|
|
- `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.
|
|
|
|
**`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`: the `Done` children about to be unit-merged
|
|
whose own review range contributed nothing (computed the same way as `PreviewMerge`'s
|
|
`isEmpty`, before the merge starts so it reflects what's about to be approved). Surfaces a
|
|
child that reported `CLAUDEDO_BLOCKED` and committed no code — previously that child reached
|
|
`Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real
|
|
change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from
|
|
`result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty.
|
|
- `skipVerify=true` (also on `MergeTask`/`ContinueMerge`) is batch mode: the merge lands but the
|
|
list's verify command is deferred and the task stays `WaitingForReview` with mergeStatus
|
|
`merged_verify_pending`. One `VerifyMerges` call after the batch runs the verify once and
|
|
promotes them — see [review-merge.md](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: 0` alone reads as "tiny", not "nothing to review") — the gap that
|
|
let two blocked planning children reach `Done` with unmerged empty branches unnoticed.
|
|
- A worktree-less handler task has no separate branch to `merge-tree`-preview (its commits
|
|
already sit in `list.WorkingDir`) — `PreviewMergeCoreAsync` falls back to a synthetic `clean`
|
|
preview over its own `HandlerBaseCommit..HandlerHeadCommit` diff-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 `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**.
|
|
`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 `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.
|
|
- **`treatWaitingForChildrenAsBusy` pitfall (default `false`, backward-compatible):** a planning
|
|
parent with children goes `Running` → `WaitingForChildren` while children are still working,
|
|
and by default that already counts as "changed" (it's outside `Queued`/`Running`) — so waiting
|
|
on a parent returns immediately even though the unit isn't done. Set the flag to keep polling
|
|
through `WaitingForChildren`; the call then only reports changed once the parent reaches
|
|
`WaitingForReview` or 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_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](usage-monitoring.md) 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.
|
|
|
|
**`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](./worker-task-pipeline.md)'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.
|