diff --git a/docs/air-extensions.md b/docs/air-extensions.md index e9c98251..c59f7877 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -187,6 +187,7 @@ The adapter sends these keys only to an AIR client. "AIR" in the Gate column mea | `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR | | `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR | | `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR | +| `mcp` | MCP tool call `_meta.jetbrains.air` | `{server, tool}` | AIR | | `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR | | `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation | | `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR | @@ -325,10 +326,14 @@ For every tool, a client that is not AIR also gets these upstream fields: | `Skill` | `kind: other`, `title: "Load skill: "`. | `_meta.jetbrains.air.skill`. | none | | `TodoWrite`, `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | The stream reports them as a standard `plan` with entries. Only a permission request shows them as a tool call. | A plan that repeats the previous plan is not sent. | Every plan goes out. | | `ReportFindings` | `kind: think`, `title: "Report findings"`. | With `rawInputRendering`, no copy of the findings in `content`. | none | -| MCP tools (`mcp__*`) and other tools | `kind: other`, `title` is the tool name. The result text goes to `content`. | `_meta.is_mcp_tool_call` for MCP. | none | +| MCP tools (`mcp__*`) and other tools | `kind: other`, `title` is the tool name. The result text goes to `content`. | `_meta.is_mcp_tool_call` for MCP, and `_meta.jetbrains.air.mcp = {server, tool}`. | none | | Context compaction | `compaction_update` when the client declares `session.compaction`. Otherwise a synthetic tool call. | Both forms carry `_meta.jetbrains.air.contextCompaction`. | See [Context compaction](#context-compaction). | | Background work that is not a subagent | none | With `asyncTasks`, async task updates. | none | +`_meta.jetbrains.air.mcp` comes from the tool name `mcp____`, on `tool_call` and on a `tool_call_update` that changes it. +`server` is the part up to the next `__`. `tool` is the whole rest and can contain `__`, as in Claude Code. +A name that does not split (`mcp__x`) gets no `mcp` key, and `_meta.is_mcp_tool_call` still marks it. + ## Diff patch The diff patch extension lets the adapter send one compact Git patch instead of file text snapshots. @@ -603,6 +608,7 @@ The client already has the rest of the tool call, because the adapter sends the - `content` is present only with an exact preview patch for a `diffPatch` client. - `locations` is present only when a valid `blockedPath` is not a location of the tool call yet. - `_meta.claudeCode.mcpServer` names the MCP server of an `mcp__*` tool. + It is the server name as authored in the config. `_meta.jetbrains.air.mcp.server` on the tool call is the normalized name (characters outside `[a-zA-Z0-9_-]` replaced by `_`), so the two can differ. A client that is not AIR gets the upstream request `toolCall`: `toolCallId`, `name`, `status: pending`, the whole `rawInput`, `title`, `kind`, `content`, and `locations`. Its `_meta.claudeCode` carries `toolName`, and `parentToolUseId` for a subagent tool call, next to `mcpServer`. diff --git a/src/air-extension.ts b/src/air-extension.ts index ef8c741f..5d9731fc 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -45,6 +45,7 @@ export const AIR_SESSION_WORKTREES_CAPABILITY = "sessionWorktrees"; export const AIR_COMMAND_TITLE_KEY = "commandTitle"; export const AIR_SUBAGENT_KEY = "subagent"; export const AIR_SKILL_KEY = "skill"; +export const AIR_MCP_KEY = "mcp"; export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction"; export const AIR_GOAL_KEY = "goal"; export const AIR_KIND_KEY = "kind"; diff --git a/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl b/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl index 03806e4e..6975509a 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl @@ -1,6 +1,6 @@ {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true,"jetbrains":{"air":{"mcp":{"server":"docs","tool":"search"},"version":1}}},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}} {"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"mcp__docs__search","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"}}} diff --git a/src/tests/tool-call-contract.test.ts b/src/tests/tool-call-contract.test.ts index 813c9a8e..e5fc5b6c 100644 --- a/src/tests/tool-call-contract.test.ts +++ b/src/tests/tool-call-contract.test.ts @@ -8,7 +8,7 @@ import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js"; import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js"; import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js"; -import { AcpToolCallRenderer } from "../tool-calls/renderer.js"; +import { AcpToolCallRenderer, mcpToolName } from "../tool-calls/renderer.js"; import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../tools.js"; const logger = { log: () => {}, error: () => {} }; @@ -563,6 +563,30 @@ describe("the ACP tool call contract", () => { expect(updates[0]).not.toHaveProperty("rawOutput"); }); + /** An AIR client gets the MCP server and tool under jetbrains.air.mcp; other clients do not. */ + it("sends the MCP server and tool under jetbrains.air.mcp for AIR only", () => { + const { call } = report(terminalAir, "mcp__docs__search", { q: "x" }); + expect(call._meta).toEqual({ + claudeCode: { toolName: "mcp__docs__search" }, + jetbrains: { air: { version: 1, mcp: { server: "docs", tool: "search" } } }, + is_mcp_tool_call: true, + }); + const plain = report({}, "mcp__docs__search", { q: "x" }); + expect(plain.call._meta).toEqual({ claudeCode: { toolName: "mcp__docs__search" } }); + }); + + /** A refinement that carries jetbrains.air.mcp again loses it once the tool_call sent it. */ + it("does not repeat jetbrains.air.mcp on the tool_call_update", () => { + const renderer = AcpToolCallRenderer.for(terminalAir); + const toolUse = { id: "t", name: "mcp__docs__search", input: { q: "x" } }; + const filter = new ChangedMetaFilter(); + filter.apply(renderer.toolCall(toolUse, { cwd: "/work" }) as any); + const refinement = renderer.refinement(toolUse, "/work"); + expect((refinement._meta as any).jetbrains.air.mcp).toEqual({ server: "docs", tool: "search" }); + const update = filter.apply(refinement as any) as any; + expect(update?._meta?.jetbrains).toBeUndefined(); + }); + it.each(["TaskOutput", "TaskStop"])("reports %s through the generic reporter", (name) => { const { call, updates } = report(terminalAir, name, { task_id: "b1" }, { content: "done" }); expect(call).toMatchObject({ title: name, kind: "other", content: [] }); @@ -639,6 +663,25 @@ describe("tool call reports outside the tool_use stream", () => { }); }); +describe("mcpToolName", () => { + /** Splits at the first separator after the prefix; the tool keeps any later separator. */ + it.each([ + ["mcp__docs__search", { server: "docs", tool: "search" }], + [ + "mcp__plugin_documents_docs__doc_export", + { server: "plugin_documents_docs", tool: "doc_export" }, + ], + ["mcp__srv__a__b", { server: "srv", tool: "a__b" }], + ])("parses %s", (name, expected) => { + expect(mcpToolName(name)).toEqual(expected); + }); + + /** Names that are not a complete mcp__server__tool name give no result. */ + it.each(["mcp__x", "mcp____tool", "mcp__srv__", "Bash"])("rejects %s", (name) => { + expect(mcpToolName(name)).toBeUndefined(); + }); +}); + describe("ChangedMetaFilter", () => { it("sends each _meta key of a tool call only when it changes", () => { const filter = new ChangedMetaFilter(); diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index a3b90630..fda4fda8 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -8,6 +8,7 @@ import type { } from "@agentclientprotocol/sdk"; import { AIR_COMMAND_TITLE_KEY, + AIR_MCP_KEY, AIR_SKILL_KEY, AIR_SUBAGENT_KEY, withAirMeta, @@ -82,6 +83,9 @@ export type ToolUpdateMeta = { /* For Skill tool calls: the skill name, and the absolute path of its SKILL.md when it could be located on disk. */ skill?: { name: string; path?: string }; + /* For MCP tool calls: the normalized server name and the tool name, so + a client need not parse the `mcp____` title. */ + mcp?: { server: string; tool: string }; [key: string]: unknown; }; }; @@ -781,10 +785,28 @@ export class AcpToolCallRenderer { ...(skillPath ? { path: skillPath } : {}), }); } + const mcp = mcpToolName(toolUse.name); + if (mcp) meta = withAirMeta(meta, AIR_MCP_KEY, mcp); return meta as ToolUpdateMeta; } } +/** + * Splits `mcp____` the way Claude Code does: the server is the + * segment up to the next `__`, the tool is the whole rest. `server` is the + * normalized name, not the config key. + */ +export function mcpToolName(name: string): { server: string; tool: string } | undefined { + const prefix = "mcp__"; + if (!name.startsWith(prefix)) return undefined; + const rest = name.slice(prefix.length); + const separator = rest.indexOf("__"); + if (separator <= 0) return undefined; + const tool = rest.slice(separator + 2); + if (!tool) return undefined; + return { server: rest.slice(0, separator), tool }; +} + /** The input with the path of the plan file in place of the plan text. */ function planFileInput(rawInput: unknown, planFilePath: string): Record { const input =