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 |
| `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: <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. |
| `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). |
| 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
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`.

View File

@@ -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";

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":{"_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":{"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"}}}

View File

@@ -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();

View File

@@ -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__<server>__<tool>` 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__<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. */
function planFileInput(rawInput: unknown, planFilePath: string): Record<string, unknown> {
const input =