mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
## Summary In an ACP client, `/mcp` showed the CLI text "… Use `/mcp` in the terminal for details." The user of an ACP client has no terminal. In SDK mode, Claude Code also refuses `/mcp reconnect|enable|disable` with "Reconnect, enable, and disable aren't available in this session." and changes nothing. - `/mcp` and `/mcp reconnect|enable|disable [<server>|all]` still run as a normal Claude Code turn. The adapter replaces the text of the local command, through the same hook that `/usage` uses. Prompt order, cancel, and the provider update stay with the turn. - For an action, the adapter runs it in that turn through the SDK control API (`reconnectMcpServer`, `toggleMcpServer`). It shows one result line for each server, then the status list. - The status list comes from `query.mcpServerStatus()`. It has a summary line and groups by status: failed, needs authentication, connecting, connected, and disabled on one line. A failed server shows a short cleaned error. A list is used and not a table, because a table breaks words in narrow columns. - Useful CLI sentences stay, for example "There's no MCP server named …". Sentences that point to the terminal are removed. - After an explicit reconnect, a server from the ACP session that needs authentication starts the existing MCP OAuth flow, when the client supports URL elicitation. - `/mcp` is advertised with the input hint `[reconnect|enable|disable [<server>|all]]` and is hidden on history replay. ## Other commits in this branch This branch also carries commits of other work: skill command metadata for AIR, a faster `session/load` replay, and the resumed permission mode. They can move to separate PRs if preferred. ## Tests - `src/tests/mcp-command.test.ts` (61 tests). The fake CLI answers with the real SDK-mode texts and message shapes (synthetic assistant + result) and does not change any status. - A live run through the adapter with a broken and a working stdio server: `/mcp`, `/mcp disable good`, `/mcp enable good`, `/mcp reconnect broken`, `/mcp reconnect nosuch`. - `compare.ts` allows one new difference against the origin/main baseline: the `mcp` command in `available_commands_update`. The baseline files are unchanged. - `npm run check` and `npm run test:run` pass.
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk";
|
||
import { z } from "zod";
|
||
|
||
const countSchema = z.number().finite().nonnegative();
|
||
const percentSchema = countSchema.max(100);
|
||
const usageWindowSchema = z
|
||
.object({ utilization: percentSchema.nullable(), resets_at: z.string().nullable() })
|
||
.nullable()
|
||
.optional();
|
||
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
|
||
const behaviorPeriodSchema = z.object({
|
||
request_count: countSchema,
|
||
session_count: countSchema,
|
||
mcp_servers: z.array(contributionSchema),
|
||
});
|
||
const modelUsageSchema = z.object({
|
||
inputTokens: countSchema,
|
||
outputTokens: countSchema,
|
||
cacheReadInputTokens: countSchema,
|
||
cacheCreationInputTokens: countSchema,
|
||
});
|
||
|
||
const usageResponseSchema = z.object({
|
||
session: z.object({
|
||
total_cost_usd: countSchema,
|
||
total_api_duration_ms: countSchema,
|
||
total_duration_ms: countSchema,
|
||
model_usage: z.record(z.string(), modelUsageSchema),
|
||
}),
|
||
subscription_type: z.string().nullable(),
|
||
rate_limits_available: z.boolean(),
|
||
rate_limits: z
|
||
.object({
|
||
five_hour: usageWindowSchema,
|
||
seven_day: usageWindowSchema,
|
||
seven_day_oauth_apps: usageWindowSchema,
|
||
seven_day_opus: usageWindowSchema,
|
||
seven_day_sonnet: usageWindowSchema,
|
||
model_scoped: z
|
||
.array(
|
||
z.object({
|
||
display_name: z.string(),
|
||
utilization: percentSchema.nullable(),
|
||
resets_at: z.string().nullable(),
|
||
}),
|
||
)
|
||
.optional(),
|
||
extra_usage: z
|
||
.object({
|
||
is_enabled: z.boolean(),
|
||
monthly_limit: countSchema.nullable(),
|
||
used_credits: countSchema.nullable(),
|
||
utilization: percentSchema.nullable(),
|
||
currency: z.string().nullable().optional(),
|
||
})
|
||
.nullable()
|
||
.optional(),
|
||
})
|
||
.nullable(),
|
||
behaviors: z.object({ day: behaviorPeriodSchema, week: behaviorPeriodSchema }).nullable(),
|
||
});
|
||
|
||
/** Validate the experimental SDK response at the runtime boundary. */
|
||
export function parseUsageResponse(value: unknown): SDKControlGetUsageResponse | null {
|
||
const parsed = usageResponseSchema.safeParse(value);
|
||
// Validate only the fields the renderer reads, but preserve the complete
|
||
// response. The experimental API may add values to unused subtrees without
|
||
// making the useful, already-validated portion unsafe to render.
|
||
return parsed.success ? (value as SDKControlGetUsageResponse) : null;
|
||
}
|
||
|
||
export function isUsageCommandText(text: string): boolean {
|
||
return text.trim() === "/usage";
|
||
}
|
||
|
||
function usageBar(percent: number): string {
|
||
const cells = 20;
|
||
const clamped = Math.max(0, Math.min(100, percent));
|
||
const filled = clamped === 0 ? 0 : Math.max(1, Math.round((clamped / 100) * cells));
|
||
return `${"█".repeat(filled)}${"░".repeat(cells - filled)}`;
|
||
}
|
||
|
||
/** Escape the Markdown syntax characters of `value` and fold its line breaks. */
|
||
export function escapeMarkdown(value: string): string {
|
||
return value.replace(/([\\`*_[\]<>|])/g, "\\$1").replace(/[\r\n]+/g, " ");
|
||
}
|
||
|
||
function formatDuration(milliseconds: number): string {
|
||
const seconds = Math.max(0, Math.round(milliseconds / 1000));
|
||
if (seconds < 60) return `${seconds}s`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainder = seconds % 60;
|
||
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
|
||
}
|
||
|
||
function formatCount(value: number): string {
|
||
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(
|
||
value,
|
||
);
|
||
}
|
||
|
||
function formatReset(value: string | null): string {
|
||
if (!value) return "";
|
||
const reset = new Date(value);
|
||
if (Number.isNaN(reset.getTime())) return ` · Resets ${escapeMarkdown(value)}`;
|
||
return ` · Resets ${new Intl.DateTimeFormat("en", {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
timeZoneName: "short",
|
||
}).format(reset)}`;
|
||
}
|
||
|
||
function appendLimit(
|
||
lines: string[],
|
||
label: string,
|
||
window: { utilization: number | null; resets_at: string | null } | null | undefined,
|
||
): void {
|
||
if (!window || window.utilization === null) return;
|
||
lines.push(
|
||
`**${escapeMarkdown(label)}** — **${window.utilization}%**${formatReset(window.resets_at)}`,
|
||
"",
|
||
`\`${usageBar(window.utilization)}\``,
|
||
"",
|
||
);
|
||
}
|
||
|
||
function appendContributions(
|
||
lines: string[],
|
||
label: string,
|
||
period: {
|
||
request_count: number;
|
||
session_count: number;
|
||
mcp_servers: { name: string; pct: number }[];
|
||
},
|
||
): void {
|
||
lines.push(
|
||
"",
|
||
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
|
||
);
|
||
if (period.mcp_servers.length === 0) return;
|
||
lines.push("", "| MCP server | Usage |", "|:--|--:|");
|
||
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {
|
||
lines.push(`| ${escapeMarkdown(server.name)} | \`${usageBar(server.pct)}\` ${server.pct}% |`);
|
||
}
|
||
}
|
||
|
||
/** Render the SDK's structured `/usage` response as Markdown. */
|
||
export function formatUsageResponse(usage: SDKControlGetUsageResponse): string {
|
||
const lines = ["## Usage"];
|
||
if (usage.subscription_type) {
|
||
lines.push("", `> Claude ${escapeMarkdown(usage.subscription_type)} subscription usage`);
|
||
}
|
||
|
||
if (usage.rate_limits_available && usage.rate_limits) {
|
||
const limitLines: string[] = [];
|
||
appendLimit(limitLines, "5-hour limit", usage.rate_limits.five_hour);
|
||
appendLimit(limitLines, "Weekly · all models", usage.rate_limits.seven_day);
|
||
const modelWindows = usage.rate_limits.model_scoped ?? [];
|
||
for (const model of modelWindows) {
|
||
appendLimit(limitLines, `Weekly · ${model.display_name}`, model);
|
||
}
|
||
if (modelWindows.length === 0) {
|
||
appendLimit(limitLines, "Weekly · Opus", usage.rate_limits.seven_day_opus);
|
||
appendLimit(limitLines, "Weekly · Sonnet", usage.rate_limits.seven_day_sonnet);
|
||
}
|
||
if (limitLines.length > 0) {
|
||
if (limitLines.at(-1) === "") limitLines.pop();
|
||
lines.push("", "### Limits", "", ...limitLines);
|
||
}
|
||
}
|
||
|
||
const models = Object.values(usage.session.model_usage);
|
||
const totals = models.reduce(
|
||
(sum, model) => ({
|
||
input: sum.input + model.inputTokens,
|
||
output: sum.output + model.outputTokens,
|
||
cacheRead: sum.cacheRead + model.cacheReadInputTokens,
|
||
cacheWrite: sum.cacheWrite + model.cacheCreationInputTokens,
|
||
}),
|
||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||
);
|
||
lines.push(
|
||
"",
|
||
"---",
|
||
"",
|
||
"### This session",
|
||
"",
|
||
"| Cost | API time | Active |",
|
||
"|:--|:--|:--|",
|
||
`| $${usage.session.total_cost_usd.toFixed(2)} | ${formatDuration(usage.session.total_api_duration_ms)} | ${formatDuration(usage.session.total_duration_ms)} |`,
|
||
"",
|
||
"| Breakdown | Tokens |",
|
||
"|:--|--:|",
|
||
`| Input | ${formatCount(totals.input)} |`,
|
||
`| Output | ${formatCount(totals.output)} |`,
|
||
`| Cache read | ${formatCount(totals.cacheRead)} |`,
|
||
`| Cache write | ${formatCount(totals.cacheWrite)} |`,
|
||
);
|
||
|
||
if (usage.behaviors) {
|
||
lines.push(
|
||
"",
|
||
"---",
|
||
"",
|
||
"### What’s using your limits?",
|
||
"",
|
||
"> Approximate, overlapping measures · this machine only · excludes claude.ai",
|
||
);
|
||
appendContributions(lines, "Last 24h", usage.behaviors.day);
|
||
appendContributions(lines, "Last 7d", usage.behaviors.week);
|
||
}
|
||
return lines.join("\n");
|
||
}
|