feat: add the MCP server and tool to AIR tool calls (#1359)

AIR gets `_meta.jetbrains.air.mcp = {server, tool}` on MCP tool calls,
so the MCP card does not depend on the title format.

The adapter splits the `mcp__<server>__<tool>` tool name the way Claude
Code does. `server` is the normalized name. Clients other than AIR get
nothing new.

Split from IJAI-872. The SDK does not expose MCP progress yet, so this
PR does not forward it.
This commit is contained in:
Mark Tkachenko
2026-10-15 17:04:17 +02:00
committed by GitHub
parent 78a29f58ba
commit 872e93f11f
5 changed files with 75 additions and 3 deletions

View File

@@ -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 | | `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 | | `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR |
| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | 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 | | `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 | | `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 | | `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: <name>"`. | `_meta.jetbrains.air.skill`. | none | | `Skill` | `kind: other`, `title: "Load skill: <name>"`. | `_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. | | `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 <n> findings"`. | With `rawInputRendering`, no copy of the findings in `content`. | none | | `ReportFindings` | `kind: think`, `title: "Report <n> 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). | | 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 | | Background work that is not a subagent | none | With `asyncTasks`, async task updates. | none |
`_meta.jetbrains.air.mcp` comes from the tool name `mcp__<server>__<tool>`, 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 ## Diff patch
The diff patch extension lets the adapter send one compact Git patch instead of file text snapshots. 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. - `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. - `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. - `_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`. 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`. Its `_meta.claudeCode` carries `toolName`, and `parentToolUseId` for a subagent tool call, next to `mcpServer`.

View File

@@ -45,6 +45,7 @@ export const AIR_SESSION_WORKTREES_CAPABILITY = "sessionWorktrees";
export const AIR_COMMAND_TITLE_KEY = "commandTitle"; export const AIR_COMMAND_TITLE_KEY = "commandTitle";
export const AIR_SUBAGENT_KEY = "subagent"; export const AIR_SUBAGENT_KEY = "subagent";
export const AIR_SKILL_KEY = "skill"; export const AIR_SKILL_KEY = "skill";
export const AIR_MCP_KEY = "mcp";
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction"; export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
export const AIR_GOAL_KEY = "goal"; export const AIR_GOAL_KEY = "goal";
export const AIR_KIND_KEY = "kind"; export const AIR_KIND_KEY = "kind";

View File

@@ -1,6 +1,6 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}} {"kind":"sessionUpdate","payload":{"sessionId":"<session>","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":"<session>","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","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":"<session>","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}

View File

@@ -8,7 +8,7 @@ import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js"; import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js"; import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.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"; import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../tools.js";
const logger = { log: () => {}, error: () => {} }; const logger = { log: () => {}, error: () => {} };
@@ -563,6 +563,30 @@ describe("the ACP tool call contract", () => {
expect(updates[0]).not.toHaveProperty("rawOutput"); 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) => { it.each(["TaskOutput", "TaskStop"])("reports %s through the generic reporter", (name) => {
const { call, updates } = report(terminalAir, name, { task_id: "b1" }, { content: "done" }); const { call, updates } = report(terminalAir, name, { task_id: "b1" }, { content: "done" });
expect(call).toMatchObject({ title: name, kind: "other", content: [] }); 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", () => { describe("ChangedMetaFilter", () => {
it("sends each _meta key of a tool call only when it changes", () => { it("sends each _meta key of a tool call only when it changes", () => {
const filter = new ChangedMetaFilter(); const filter = new ChangedMetaFilter();

View File

@@ -8,6 +8,7 @@ import type {
} from "@agentclientprotocol/sdk"; } from "@agentclientprotocol/sdk";
import { import {
AIR_COMMAND_TITLE_KEY, AIR_COMMAND_TITLE_KEY,
AIR_MCP_KEY,
AIR_SKILL_KEY, AIR_SKILL_KEY,
AIR_SUBAGENT_KEY, AIR_SUBAGENT_KEY,
withAirMeta, withAirMeta,
@@ -82,6 +83,9 @@ export type ToolUpdateMeta = {
/* For Skill tool calls: the skill name, and the absolute path of its /* For Skill tool calls: the skill name, and the absolute path of its
SKILL.md when it could be located on disk. */ SKILL.md when it could be located on disk. */
skill?: { name: string; path?: string }; 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__<server>__<tool>` title. */
mcp?: { server: string; tool: string };
[key: string]: unknown; [key: string]: unknown;
}; };
}; };
@@ -781,10 +785,28 @@ export class AcpToolCallRenderer {
...(skillPath ? { path: skillPath } : {}), ...(skillPath ? { path: skillPath } : {}),
}); });
} }
const mcp = mcpToolName(toolUse.name);
if (mcp) meta = withAirMeta(meta, AIR_MCP_KEY, mcp);
return meta as ToolUpdateMeta; return meta as ToolUpdateMeta;
} }
} }
/**
* Splits `mcp__<server>__<tool>` 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. */ /** The input with the path of the plan file in place of the plan text. */
function planFileInput(rawInput: unknown, planFilePath: string): Record<string, unknown> { function planFileInput(rawInput: unknown, planFilePath: string): Record<string, unknown> {
const input = const input =