mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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`.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"}}}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user