Files
claude-agent-acp/src/mcp-command.ts
nikita-ashihmin 03d84f6663 feat: add the AIR sessionIndex extension for fast session lists (#1268)
## Summary

Adds the opt-in AIR `sessionIndex` extension. With it, an AIR client can
show the latest sessions of a project quickly, with their state, and can
rename, archive and unarchive them over ACP. The full contract is in
`docs/air-extensions.md` ("Session index").

The extension follows ACP RFDs. The transport differs: it uses
`_meta.jetbrains.air` request options and row fields, `_session/*`
methods, and AIR capabilities that are not advertised under ACP v2. The
`archived` list filter also differs (see below).

- **Session list extensions**
(agentclientprotocol/agent-client-protocol#2323):
- `limit`, ordered by last user activity (`lastPromptAt ?? updatedAt`),
and `includeWorktrees`.
  - Row fields `lastPromptAt`, `model` and `forkedFrom`.
- `state`: `running`, `idle`, `requires_action`, and `error`, which the
RFD lacks.
  - `lastTurnEndedAt`.
- `cost`: the SDK's `total_cost_usd` for a session that runs on this
connection only. No cost is read from transcripts.
  - Live updates come from an AIR list subscription (below).
- **Session archive** (agentclientprotocol/agent-client-protocol#2161):
- `archived` is on every row, `session_info_update` is sent on a change,
and an unknown session is `-32002`.
- The `archived` list filter has three values: `"unarchived"` (the
default), `"archived"` (archived sessions only) and `"all"` (both, in
one order). #2161 has a boolean here. Its `false` is `"unarchived"`, its
`true` is `"all"`, and `"archived"` has no counterpart.
- **Capabilities**: an AIR client that declares `sessionIndex` gets
these in `_meta.jetbrains.air.capabilities`. The client declares only
`sessionIndex`.
  - `sessionIndex`.
- `sessionArchive`: `_session/archive` and `_session/unarchive`. The RFD
capability is `archive` (#2161).
- `sessionRename`: `_session/rename`. The RFD has capability `setTitle`
and method `session/set_title`
(agentclientprotocol/agent-client-protocol#1987).
- `sessionListSubscribe`: `_session/list/subscribe`,
`_session/list/unsubscribe` and `_session/list/changes`. No RFD
counterpart.

### Clients without `sessionIndex`

Clients that don't declare `sessionIndex`, which means non-AIR clients
and released AIR builds, keep the current behaviour. They get the same
SDK `listSessions` path, page size, cursor and initialize capabilities,
with no watchers and no notifications. There are two deliberate
deviations, both strictly safer:

1. **`session/delete` from an AIR client without `sessionIndex`.**
Released AIR builds use delete as "Done". Instead of deleting the
transcript (data loss), the adapter archives the session in AIR's own
Claude format (below). Archived sessions are hidden from that client's
list, so "Done" looks the same and nothing is lost. An unknown session
keeps the SDK's error. Other clients get the SDK delete, as before.
2. **`session/close`** of a session that is not loaded here returns `{}`
instead of throwing.

### Sessions open in another process

`session/load`, `session/resume` and `session/prompt` work on a session
that another live Claude Code process has open, for every client, as the
Claude CLI does. Rename, archive, unarchive and delete also go ahead,
writing records or deleting transcripts as the CLI's `/rename` and
delete do. The other process keeps running.

### Archive format and rule

The archive state uses AIR's native Claude archive format
(`ClaudeStoreThreadBackend.appendTitleRecords`, ADR 0140), so AIR's
native path and the adapter read and write the same state.

- **Archive** appends `{"type":"custom-title","customTitle":"[archived]
<title>",…}` and `{"type":"agent-name","agentName":"[archived]
<title>",…}` to every transcript copy. **Unarchive** appends the same
pair without the prefix. Titles are cut to the CLI's 200 characters.
- **Archived is decided by the agent name first**, as in AIR's native
Claude provider. A session's name is its last top-level `agentName`,
else the custom title the SDK reports. The session is archived exactly
when that name starts with `[archived] `. The listed title comes from
the same source, shown without the prefix.
- Title records are metadata: `updatedAt` and the list order do not
move.

### Rename, archive and delete

- **Rename** writes the same title pair. An archived session keeps its
prefix. A session running here is renamed through its CLI's
`rename_session` control request, serialised with auto-titling. Other
sessions get the records plus the `custom-title.json` sidecar the CLI
writes. No generated title replaces a client title, but a title stored
later by someone else (a `/rename`) is still adopted.
- **Archiving stops the running session.** `_session/archive` of a
session loaded on this connection does four things:
  1. It interrupts the running turn.
2. It retitles the session through its CLI. The adapter also appends the
matching `agent-name` record to the CLI's transcript, because the CLI
writes that record only later.
  3. It closes the session as `session/close` does.
4. It archives the transcripts once more if the closed CLI left them
unarchived.

The client then gets `session_info_update` with `archived: true`.
Unarchive does not reopen the session. A session that is not loaded here
is archived by writing the title records.
- **Delete** for a `sessionIndex` client deletes every copy of the
session and its `<sessionId>/` directories. A session running here is
closed first.

### Implementation (`src/session-index/`)

- **Index**:
- Reads the project dirs with `readdir` + `stat`, and keeps a 2000-entry
metadata LRU keyed by (path, mtime, size).
- Cache misses are read in parallel through `getSessionInfo`, plus a
growing tail read (64 KB → 4 MB) for the last prompt, last message, turn
end and agent name.
- SDK semantics are kept: sidecar titles, `continued-in`, long-path
hashes, darwin case, and first-prompt extraction (ported, and
fuzz-compared with the SDK).
- The `cwd` scope follows the SDK `listSessions`. Worktrees expand to
the same relative subdirectory, and a session copied to several project
directories is listed from its newest copy.
- The keyset cursor is bound to `cwd`, `archived` and
`includeWorktrees`. Session ids match exactly.
- **State**:
  - For sessions of this process, the SDK state.
- For other sessions, the Claude Code live registry
(`~/.claude/sessions/<pid>.json`, with pid + procStart liveness and
Claude Desktop's rules) or the transcript tail. The registry is read
only for the row state.
- `error` means the session is not running and its last turn ended with
an error, not a cancel.

Documented in `docs/air-extensions.md`, with an extension → RFD mapping
table and the remaining differences.

### Measured on a real `~/.claude` (233 transcripts, read-only)

| Case | New path | Old SDK path |
|---|---|---|
| cwd, limit 50, cold process | ~80 ms | 78–156 ms (all rows) |
| cwd, limit 50, warm | 1.3–4 ms | — |

## Session list subscription

Replaces polling the first page. It is only for a client that declared
`sessionIndex`; the agent advertises `sessionListSubscribe` with it,
never under ACP v2.

- **Subscribe**: `_session/list/subscribe { cwd }` returns `{
subscriptionId }`. `cwd` must be absolute, else `-32602`; other params
are ignored.
- The scope is `cwd` and the same subdirectory in every linked worktree,
in any archive state, without sidechains.
- **Changes**: `_session/list/changes { subscriptionId, sessions,
removed }`.
- `sessions` holds the full `session/list` row of each session that
appeared, or whose title, `lastPromptAt`, `state`, `lastTurnEndedAt`,
`cost`, `model`, `forkedFrom` or `archived` changed since the last row
sent.
- The first change of a session after subscribe is sent in full. After
that, a change of `updatedAt` alone sends nothing.
  - `removed` lists sessions that were deleted or moved out of scope.
  - Archive and unarchive are row changes.
- A session is sent at most once a second, with one notification per
subscription per pass.
- **Unsubscribe**: `_session/list/unsubscribe { subscriptionId }`
returns `{}` and is idempotent.
- A subscription lives until unsubscribe or until the connection closes.
- A connection has at most 128 subscriptions. The 129th is `-32602` with
`data.reason: "too_many_subscriptions"`.
- Subscriptions of the same `cwd` share one watch and are notified
independently.
- **Delivery** is best effort, with no resync and no sequence numbers. A
rescan every 10 s (and 1 s after a project directory watch opens) sends
whatever differs.
- **Implementation** (`list-subscriptions.ts`, `session-info.ts`):
- A non-recursive `fs.watch` on the scope's project dirs, the registry
and the projects root.
- A transcript event re-reads only that transcript, and a registry event
reads only that `<pid>.json`. Sessions running here push their SDK state
at once.
- Subscribe reads no session row. The rescan reads only transcripts
whose stat changed or whose read failed.

### Measured: one subscription on the 5,200-session project (Docker
`node:22`, 2 CPU, 2 GB, shared host)

300 s of churn: 20 sessions appending 10/s each (updatedAt-only),
registry status flips every 200 ms, and a session created every 2 s and
deleted 10 s later.

| | Old `list_changed` watcher | Subscription |
|---|---|---|
| Agent CPU p50 / max | 19% / — | 12.9% / 49% |
| Event loop p99 (p50 / max of 1 s windows) | — | 16 / 31 ms; worst
single delay 87 ms |
| Change latency p50 / p95 | 950 ms (hint) | create 291 / 570 ms, delete
279 / 506 ms; registry state 902 / 990 ms* |
| Notifications / min | — | 90 (896 rows, 0 rows for updatedAt-only
appends) |
| RSS | flat ~160 MB | 159 → 198 MB (heap flat ~50 MB) |

\* The registry flips the same 5 sessions every 200 ms, so each is held
to the once-a-second limit. Latency is measured from the first unsent
change.

These numbers were measured with an earlier subscribe that read every
row up front. Subscribe now reads no rows, so that one-off cost no
longer occurs. The lazy subscription has not been re-measured yet.

## Load test: 10,000 sessions (Docker, 2 CPU, 2 GB)

Calibrated data: 10k transcripts, 15 GB, 36k subagent files, and one
5,200-session project. Sizes match a real profile (p50 311K / p90 2.67M
/ p99 20.5M).

| Scenario (limit 50) | Before | After |
|---|---|---|
| Big project, first page cold / warm p50 | out of memory | 225 / 19 ms
|
| No cwd, cold / warm p50 | 1,722 / 178 ms | 209 / 45 ms |
| Page through 4,637 rows | 457 s | 11.4 s cold, 2.9 s warm |
| Delete p50 / 100 concurrent | 23.8 ms / 4.4 s | 7.8 ms / 0.8 s |
| 300 s of churn (60k changes) | out of memory after 3 s | list p50 54
ms, 0 stalls >50 ms, RSS flat ~160 MB |

## Streaming: keep the event loop responsive under a backlog

Under a backlog of buffered SDK messages, the adapter could freeze its
event loop for seconds. Every `query.next()` on a non-empty SDK queue
resolves in a microtask, so `runConsumer` forwarded the whole backlog
without reaching a macrotask boundary. Incoming JSON-RPC, including
`session/cancel`, waited until the backlog drained.

1. **Consumer yields to the event loop.** A shared `EventLoopYielder`
(`src/utils.ts`) gives the event loop a turn via `setImmediate` once the
current iteration has run for 8 ms. Order, content and cancel semantics
are unchanged.
2. **No per-message copy in `nodeToWebWritable`** for byte streams.
Object-mode streams keep the copy.
3. **Diff memo.** The approval preview and the PostToolUse patch no
longer diff the same texts twice. Timed-out diffs are not remembered.
4. **Prefix guards** in `parseMcpCommand` and `sanitizeTitle`. Both give
identical output.
5. **Replay yields between history messages**, with the same budget.

With 10 sessions × 1 MB, the max event-loop delay went from 4205 ms to
1626 ms, and ping p95 from 2793 ms to 262 ms (interleaved A/B, Docker
`node:22`, 2 CPU). Single-stream throughput is unchanged. Remaining
stalls are inside the Claude SDK and GC.

## Test plan

- [x] Rebased on `origin/main`. `npm run check`, `npm run build` and
`npm run test:run` pass.
- [x] Regression tests for the unchanged non-`sessionIndex` paths.
- [x] Regression tests for every review finding.
- [x] Reviews: Codex (gpt-6-astra) and a Claude review subagent, run as
independent passes against `origin/main` until a round found nothing of
normal or higher severity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:59:47 +04:00

393 lines
16 KiB
TypeScript

import type { AvailableCommand } from "@agentclientprotocol/sdk";
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
import { escapeMarkdown } from "./usage-markdown.js";
import { raceTimeoutAndAbort } from "./utils.js";
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
* servers as a list, and runs a reconnect, an enable, or a disable through
* the SDK control API. */
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
name: "mcp",
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
};
/** The time that the status read after a `/mcp` command can take. */
const MCP_STATUS_TIMEOUT_MS = 5_000;
/** The time that one reconnect, enable, or disable can take. */
const MCP_ACTION_TIMEOUT_MS = 30_000;
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
* true for `all` and for a reconnect without a server name, as in Claude
* Code. `server` is undefined for an enable or a disable without a name. */
export type McpCommand =
| { action: "status" }
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
* argument gives null, and the prompt gets no replacement. */
export function parseMcpCommand(text: string): McpCommand | null {
// The first word is `/mcp` only when the text starts with it after the
// leading whitespace. Checking that first keeps a long prompt from being
// split into words. `trimStart` removes the characters that `\s` matches.
if (!text.trimStart().startsWith("/mcp")) return null;
const words = text.trim().split(/\s+/);
if (words[0] !== "/mcp") return null;
if (words.length === 1) return { action: "status" };
const action = words[1]?.toLowerCase();
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
const server = words.slice(2).join(" ");
if (server === "all" || (server === "" && action === "reconnect")) {
return { action, all: true };
}
return server === "" ? { action, all: false } : { action, all: false, server };
}
/** Read the MCP server status for the list after a `/mcp` command. Resolves
* to null when the read fails, takes too long, or `signal` aborts. Then the
* turn keeps the text of Claude Code. */
export async function readMcpServerStatus(
query: Pick<Query, "mcpServerStatus">,
signal: AbortSignal,
logError: (message: string) => void,
): Promise<McpServerStatus[] | null> {
if (signal.aborted) return null;
try {
const outcome = await raceTimeoutAndAbort(
query.mcpServerStatus(),
MCP_STATUS_TIMEOUT_MS,
signal,
);
if (outcome.type === "timeout") {
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
}
return outcome.type === "done" ? outcome.value : null;
} catch (error) {
if (!signal.aborted) {
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
}
return null;
}
}
/** The known statuses in the order of the groups and of the summary counts.
* A disabled server is not a group. It is on one line at the end. */
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
failed: "Failed",
"needs-auth": "Needs authentication",
pending: "Connecting",
connected: "Connected",
};
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
connected: "connected",
failed: "failed",
"needs-auth": "need authentication",
pending: "connecting",
disabled: "disabled",
};
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
const MAX_ERROR_LENGTH = 160;
/** The longest segment that can be a wrapper. A longer segment is content. */
const MAX_WRAPPER_LENGTH = 60;
/** True for a server that a `/mcp reconnect` without a name must retry. */
function needsReconnect(status: McpServerStatus): boolean {
return (
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
);
}
/** The text as a Markdown code span. The fence is longer than the longest
* backtick run in the text, so the span shows the text unchanged. The user
* can copy a server name from it into `/mcp reconnect`. */
function codeSpan(text: string): string {
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
const fence = "`".repeat(longestRun + 1);
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
return `${fence}${pad}${text}${pad}${fence}`;
}
function isErrorCode(segment: string): boolean {
return /^-?\d+$/.test(segment);
}
/** True for a segment that only wraps the real error, such as
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
* Rust or TypeScript type path. */
function isWrapperSegment(segment: string): boolean {
return (
isErrorCode(segment) ||
segment.includes("::") ||
(segment.length <= MAX_WRAPPER_LENGTH &&
!/[.!?]/.test(segment) &&
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
);
}
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
function truncate(text: string): string {
const chars = Array.from(text);
if (chars.length <= MAX_ERROR_LENGTH) return text;
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
return `${cut.trimEnd()}…`;
}
/** Make an MCP error short and readable for one list item. The result has
* no leading wrapper segments, at most two sentences, and at most
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
* an error code is never alone. The result is empty for an error with only
* whitespace. */
export function cleanMcpError(error: string): string {
const segments = error.replace(/\s+/g, " ").trim().split(": ");
let start = 0;
while (
start < segments.length - 1 &&
isWrapperSegment(segments[start]!) &&
!segments.slice(start + 1).every(isErrorCode)
) {
start++;
}
const content = segments.slice(start).join(": ");
return truncate(
content
.split(/(?<=[.!?]) /)
.slice(0, 2)
.join(" "),
);
}
function toolCount(status: McpServerStatus): string | undefined {
if (!status.tools) return undefined;
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
}
/** The server name as inline code. Claude Code keys the servers by name, so
* two servers have the same name only from different scopes. Then the
* scope tells them apart. */
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
const origin = status.scope ?? status.source;
return duplicates.has(status.name) && origin
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
: codeSpan(status.name);
}
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
const error = status.error ? cleanMcpError(status.error) : "";
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
.filter((detail) => detail !== undefined)
.join("; ");
const name = serverName(status, duplicates);
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
}
function summaryLine(statuses: McpServerStatus[]): string {
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
.map((state) => ({
state,
count: statuses.filter((status) => status.status === state).length,
}))
.filter(({ count }) => count > 0);
const parts = counts.map(({ state, count }) =>
count === 1 && state === "needs-auth"
? "1 needs authentication"
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
);
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
}
/** The statuses that this adapter does not know, in the SDK order. The SDK
* can report a new status before the adapter knows it. */
function unknownStatuses(statuses: McpServerStatus[]): string[] {
return [
...new Set(
statuses
.map((status) => status.status as string)
.filter((state) => !KNOWN_STATUSES.includes(state)),
),
];
}
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
function unknownTitle(state: string): string {
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
}
/** Markdown for `/mcp`: a summary line, one group of servers for each
* status, and the disabled servers on one line. A list reads better than a
* table in a narrow chat. */
export function formatMcpStatus(statuses: McpServerStatus[]): string {
if (statuses.length === 0) return "No MCP servers are configured.";
const blocks: string[] = [];
const names = statuses.map((status) => status.name);
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
blocks.push(summaryLine(statuses));
const groups: [string, string][] = [
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
];
for (const [state, title] of groups) {
const members = statuses.filter((status) => status.status === state);
if (members.length === 0) continue;
blocks.push(
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
);
}
const disabled = statuses.filter((status) => status.status === "disabled");
if (disabled.length > 0) {
blocks.push(
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
);
}
if (statuses.some(needsReconnect)) {
blocks.push(
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
);
}
return blocks.join("\n\n");
}
/** The SDK query methods that `/mcp` uses. */
export type McpControlQuery = Pick<
Query,
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
>;
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
* keeps the text of Claude Code. `reconnected` holds the servers that the
* adapter tried to reconnect. */
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
/** Wait for one control call. The wait stops when `signal` aborts or the
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
if (signal.aborted) return { type: "aborted" };
try {
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
return outcome.type === "done" ? { type: "done" } : outcome;
} catch (error) {
return signal.aborted
? { type: "aborted" }
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
}
}
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
/** The Claude Code sentence that refuses an action in SDK mode. */
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
/** A Claude Code sentence that points to a terminal, which an ACP client
* does not have. */
function isTerminalSentence(sentence: string): boolean {
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
}
function sentences(text: string): string[] {
return text
.trim()
.split(/(?<=[.!?])\s+/)
.filter((sentence) => sentence !== "");
}
/** The servers that the action changes. A named server is always a target,
* so the control API reports its own error. */
function actionTargets(
command: Extract<McpCommand, { all: boolean }>,
statuses: McpServerStatus[],
): string[] {
if (!command.all) return command.server === undefined ? [] : [command.server];
const names = statuses
.filter((status) => {
if (command.action === "reconnect") return needsReconnect(status);
if (command.action === "enable") return status.status === "disabled";
return status.status !== "disabled";
})
.map((status) => status.name);
return [...new Set(names)];
}
function nothingToDoLine(action: McpCommand["action"]): string {
if (action === "enable") return "Every MCP server is already enabled.";
if (action === "disable") return "Every MCP server is already disabled.";
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
}
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
* the command. Claude Code refuses a reconnect, an enable, and a disable in
* SDK mode, so the adapter runs them through the SDK control API. The result
* of each server goes above the server list. An error of one server does
* not stop the others. When the first status read fails or `signal`
* aborts, the turn keeps the text of Claude Code. */
export async function runMcpCommand(
query: McpControlQuery,
command: McpCommand,
originalOutput: string,
signal: AbortSignal,
logError: (message: string) => void,
): Promise<McpCommandOutcome> {
const reconnected: string[] = [];
const keep: McpCommandOutcome = { markdown: null, reconnected };
if (command.action === "status") {
// The list replaces the Claude Code summary line.
const statuses = await readMcpServerStatus(query, signal, logError);
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
}
const before = await readMcpServerStatus(query, signal, logError);
if (!before) return keep;
const cliSentences = sentences(originalOutput).filter(
(sentence) => !isTerminalSentence(sentence),
);
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
const results: string[] = [];
if (!command.all && command.server === undefined) {
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
}
const known = new Set(before.map((status) => status.name));
const targets = actionTargets(command, before);
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
for (const server of targets) {
if (!known.has(server)) {
// Claude Code checks the name first. Its sentence then stays.
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
continue;
}
if (command.action === "reconnect") reconnected.push(server);
const call =
command.action === "reconnect"
? () => query.reconnectMcpServer(server)
: () => query.toggleMcpServer(server, command.action === "enable");
const result = await awaitCall(call, signal);
if (result.type === "aborted") return keep;
if (result.type === "done") {
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
} else {
const error = cleanMcpError(result.error);
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
}
}
const blocks: string[] = [];
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
if (kept.length > 0) blocks.push(kept.join(" "));
if (results.length > 0) blocks.push(results.join("\n"));
const after = await readMcpServerStatus(query, signal, logError);
if (signal.aborted) return keep;
if (after) blocks.push(formatMcpStatus(after));
return { markdown: blocks.join("\n\n"), reconnected };
}