MaxTimeoutSeconds was 170s against runs that take tens of minutes, forcing a dozen full-context wait rounds per long-running batch. Raise it to 900s and raise MCP_TOOL_TIMEOUT in lockstep (ClaudeProcess + every InteractiveLaunchSpecService launch spec) to 930000ms so the client connection actually stays open that long instead of aborting first. Add get_queue_state (QueueStateMcpTools): configured vs. effective parallel-slot count (via QueueService.GetSlotCountsAsync, extracted from the former GetEffectiveMaxParallelAsync), active slots with taskId + startedAt including the run_task_now override slot, and queued tasks in pick order -- so a caller can observe queue occupancy instead of inferring it from maxParallelExecutions.
160 lines
9.8 KiB
Markdown
160 lines
9.8 KiB
Markdown
# 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` |
|
|
| `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.
|
|
|
|
**`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 `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](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.
|
|
|
|
**`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.
|
|
|
|
## 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.
|