Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/acp-mcp-native-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added native callable tools for MCP servers supplied by ACP clients. ([#2002](https://github.com/PrimeIntellect-ai/prime-agent/pull/2002))
62 changes: 52 additions & 10 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ import {
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.js";
import { THINKING_LEVELS } from "./thinking-levels.js";
import { acpMcpToolNames, createAcpMcpToolDefinitions } from "./tools/acp-mcp.js";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { IpythonKernelProvisioner } from "./tools/ipython.js";
Expand Down Expand Up @@ -1129,6 +1130,7 @@ export class AgentSession {

private _resourceLoader: ResourceLoader;
private _customTools: ToolDefinition[];
private _acpMcpTools: ToolDefinition[] = [];
private _baseToolDefinitions: Map<string, ToolDefinition> = new Map();
private _cwd: string;
private _agentDir?: string;
Expand Down Expand Up @@ -1341,15 +1343,22 @@ export class AgentSession {
if (servers.length > 0) throw new Error("MCP is unavailable in this session");
return;
}
if (servers.length > 0 && !this._ipythonKernelProvisioner) {
throw new Error("ACP MCP servers require the built-in cpython tool");
}
this._assertAcpMcpToolNamesAvailable(acpMcpToolNames(servers));
if (!this._mcpManager.replaceAcpServers(servers, ownerId)) return;
this._rebuildRuntimeForAcpMcpServers();
}

async releaseAcpMcpServers(ownerId: string, serverNames: readonly string[]): Promise<void> {
if (!this._mcpManager?.canReleaseAcpServers(ownerId)) return;
if (this._mcpManager.replaceAcpServers([], ownerId)) {
// Host MCP handlers read this manager dynamically, so credentials disappear
// before the kernel-side transport is closed.
const removedToolNames = new Set(this._acpMcpTools.map((tool) => tool.name));
const activeToolNames = this.getActiveToolNames().filter((name) => !removedToolNames.has(name));
for (const name of removedToolNames) this._allowedToolNames?.delete(name);
this._acpMcpTools = [];
this._refreshToolRegistry({ activeToolNames, includeAllExtensionTools: true });
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this.agent.state.systemPrompt = this._baseSystemPrompt;
}
Expand Down Expand Up @@ -1387,9 +1396,27 @@ export class AgentSession {
}
}

private _assertAcpMcpToolNamesAvailable(names: readonly string[]): void {
const occupiedNames = new Set([
...this._baseToolDefinitions.keys(),
...this._customTools.map((tool) => tool.name),
...this._extensionRunner.getAllRegisteredTools().map((tool) => tool.definition.name),
]);
for (const name of names) {
if (occupiedNames.has(name)) {
throw new Error(`ACP MCP tool name conflicts with an existing tool: ${name}`);
}
}
}

private _rebuildRuntimeForAcpMcpServers(): void {
const previousToolNames = new Set(this._acpMcpTools.map((tool) => tool.name));
const nextToolNames = acpMcpToolNames(this._mcpManager?.getAcpServers() ?? []);
this._assertAcpMcpToolNamesAvailable(nextToolNames);
const activeToolNames = this.getActiveToolNames().filter((name) => !previousToolNames.has(name));
activeToolNames.push(...nextToolNames);
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
activeToolNames,
includeAllExtensionTools: true,
});
Comment thread
cursor[bot] marked this conversation as resolved.
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
Expand Down Expand Up @@ -4391,7 +4418,7 @@ export class AgentSession {
rlmDepth: this._rlmDepth,
rlmParentAgent: this._rlmParentAgent,
harnessState: this._loadMergedHarnessState(),
genericMcpServers: this._mcpManager?.getEnabledGenericServers(),
genericMcpServers: this._mcpManager?.getEnabledPersistentGenericServers(),
};
return buildSystemPrompt(this._baseSystemPromptOptions);
}
Expand Down Expand Up @@ -8985,14 +9012,16 @@ export class AgentSession {
const previousActiveToolNames = this.getActiveToolNames();
const allowedToolNames = this._allowedToolNames;
const registeredTools = this._extensionRunner.getAllRegisteredTools();
const sdkToolEntry = (definition: ToolDefinition) => ({
definition,
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, {
source: "sdk" as const,
}),
});
const allCustomTools = [
...registeredTools,
...this._customTools.map((definition) => ({
definition,
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, {
source: "sdk",
}),
})),
...this._customTools.map(sdkToolEntry),
...this._acpMcpTools.map(sdkToolEntry),
];
const isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name);
const allowedCustomTools = allCustomTools.filter((tool) => isAllowedTool(tool.definition.name));
Expand Down Expand Up @@ -9154,6 +9183,19 @@ export class AgentSession {
this._bindExtensionCore(this._extensionRunner);
this._applyExtensionBindings(this._extensionRunner);

const previousAcpMcpToolNames = new Set(this._acpMcpTools.map((tool) => tool.name));
const acpServers = this._mcpManager?.getAcpServers() ?? [];
if (acpServers.length > 0 && !this._ipythonKernelProvisioner) {
throw new Error("ACP MCP servers require the built-in cpython tool");
}
const acpMcpTools = this._ipythonKernelProvisioner
Comment thread
sethkarten marked this conversation as resolved.
? createAcpMcpToolDefinitions(acpServers, this._ipythonKernelProvisioner)
: [];
this._assertAcpMcpToolNamesAvailable(acpMcpTools.map((tool) => tool.name));
for (const name of previousAcpMcpToolNames) this._allowedToolNames?.delete(name);
for (const tool of acpMcpTools) this._allowedToolNames?.add(tool.name);
this._acpMcpTools = acpMcpTools;

const defaultActiveToolNames = this._baseToolsOverride ? Object.keys(this._baseToolsOverride) : ["ipython"];
const baseActiveToolNames = [...(options.activeToolNames ?? defaultActiveToolNames)];
if (this._goalState.status === "active" && this._includeGoals) {
Expand Down
16 changes: 10 additions & 6 deletions packages/coding-agent/src/core/mcp/mcp-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,19 +218,23 @@ export class McpManager {
return handlers;
}

/** Enabled persistent and session-scoped servers available through the generic kernel API. */
getEnabledGenericServers(): string[] {
const servers = Array.from(this.integrations.values())
/** Session-scoped servers supplied by the active ACP client. */
getAcpServers(): AcpMcpServerConfig[] {
return [...this.acpServers.values()];
}

/** Enabled user-declared servers available through the generic kernel API. */
getEnabledPersistentGenericServers(): string[] {
Comment thread
sethkarten marked this conversation as resolved.
return Array.from(this.integrations.values())
.filter(
(integration) =>
integration.userDeclared &&
GENERIC_SERVER_NAME_PATTERN.test(integration.server) &&
!getCatalogEntry(integration.server) &&
this.isAuthed(integration),
)
.map((integration) => integration.server);
for (const server of this.acpServers.keys()) servers.push(server);
return [...new Set(servers)].sort((left, right) => left.localeCompare(right));
.map((integration) => integration.server)
.sort((left, right) => left.localeCompare(right));
}

/** Status for the /mcp list command. */
Expand Down
99 changes: 99 additions & 0 deletions packages/coding-agent/src/core/tools/acp-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { ToolDefinition } from "../extensions/types.js";
import type { ExecuteResult } from "../kernel/index.js";
import type { AcpMcpServerConfig } from "../mcp/acp-mcp-types.js";
import type { IpythonKernelProvisioner } from "./ipython.js";

// 48 keeps `mcp_list_tools_<name>` within providers' 64-char tool-name limits.
const ACP_MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,48}$/;

export function acpMcpToolNames(servers: readonly AcpMcpServerConfig[]): string[] {
const names: string[] = [];
const seenServers = new Set<string>();
for (const server of servers) {
if (!ACP_MCP_SERVER_NAME_PATTERN.test(server.name)) {
throw new Error(`Invalid ACP MCP server name: ${server.name}`);
}
if (seenServers.has(server.name)) {
throw new Error(`Duplicate ACP MCP server: ${server.name}`);
}
seenServers.add(server.name);
names.push(`mcp_list_tools_${server.name}`, `mcp_call_${server.name}`);
}
return names;
}

function executionResult(result: ExecuteResult) {
let text = result.stdout;
if (result.stderr) text += `${text ? "\n" : ""}${result.stderr}`;
if (result.result) text += `${text ? "\n" : ""}${result.result}`;
if (result.error) text += `${text ? "\n" : ""}${result.error.traceback.join("\n")}`;
if (result.status !== "ok") {
throw new Error(text || `MCP kernel execution ${result.status}`);
}
return {
content: [{ type: "text" as const, text: text || "(empty)" }],
details: {
durationMs: result.durationMs,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
result: result.result,
},
};
}

async function executeMcpCode(provisioner: IpythonKernelProvisioner, code: string, signal: AbortSignal | undefined) {
const manager = await provisioner.ensure(() => {}, signal);
return executionResult(await manager.execute(code, { signal }));
}

export function createAcpMcpToolDefinitions(
servers: readonly AcpMcpServerConfig[],
provisioner: IpythonKernelProvisioner,
): ToolDefinition[] {
const names = acpMcpToolNames(servers);
const definitions: ToolDefinition[] = [];
for (const [index, server] of servers.entries()) {
const listToolName = names[index * 2]!;
const callToolName = names[index * 2 + 1]!;
const serverName = JSON.stringify(server.name);

definitions.push({
name: listToolName,
label: `list tools from ${server.name}`,
description:
`List every tool the "${server.name}" MCP server exposes. ` +
`Call this first, then use ${callToolName} to invoke a specific tool.`,
parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
execute: async (_toolCallId, _params, signal, _onUpdate, _ctx) => {
const code = `print(__import__("json").dumps(await mcp.list_tools(${serverName}), default=str))`;
return executeMcpCode(provisioner, code, signal);
},
});

definitions.push({
name: callToolName,
label: `call tool on ${server.name}`,
description:
`Call a tool on the "${server.name}" MCP server. ` +
`Use ${listToolName} first to discover available tool names and argument schemas.`,
parameters: {
type: "object",
properties: {
tool: { type: "string", description: `Tool name on "${server.name}".` },
arguments: { type: "object", description: "JSON arguments for the tool.", additionalProperties: true },
},
required: ["tool", "arguments"],
additionalProperties: false,
},
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
const { tool, arguments: args } = params as { tool: string; arguments: Record<string, unknown> };
const code =
`print(__import__("json").dumps(await mcp.call_tool(${serverName}, ${JSON.stringify(tool)}, ` +
`__import__("json").loads(${JSON.stringify(JSON.stringify(args ?? {}))})), default=str))`;
return executeMcpCode(provisioner, code, signal);
},
});
}
return definitions;
}
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { acpMcpToolNames, createAcpMcpToolDefinitions } from "./acp-mcp.js";
export {
type BashOperations,
type BashSpawnContext,
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/test/agent-session-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ describe("createAgentSessionFromServices", () => {
],
"owner-a",
);
expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `task`, `zebra`.");
expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`.");
expect(session.systemPrompt).not.toContain('await mcp.list_tools("task")');
expect(session.getActiveToolNames()).toEqual(expect.arrayContaining(["mcp_list_tools_task", "mcp_call_task"]));
expect(session.systemPrompt).not.toContain("task-secret");
rebuildRuntime.mockClear();
const waitForIdle = vi.spyOn(session.agent, "waitForIdle");
Expand All @@ -202,6 +204,8 @@ describe("createAgentSessionFromServices", () => {
expect(execute.mock.calls[0]?.[0]).toContain("await _prime_mcp.reload(_prime_mcp_name)");
expect(execute.mock.calls[0]?.[0]).toContain('["task"]');
expect(session.systemPrompt).toContain("Enabled generic MCP servers: `filesystem`, `zebra`.");
expect(session.getAllTools().map((tool) => tool.name)).not.toContain("mcp_call_task");
expect(session.getActiveToolNames()).not.toContain("mcp_call_task");

settingsManager.setGlobalMcpServer("added", { type: "stdio", command: "new-secret" });
settingsManager.removeGlobalMcpServer("filesystem");
Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/test/mcp-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe("McpManager", () => {
});
expect(manager.listStatus().find((s) => s.server === "remote")?.enabled).toBe(false);
expect(manager.listStatus().find((s) => s.server === "unbound")?.enabled).toBe(false);
expect(manager.getEnabledGenericServers()).toEqual([]);
expect(manager.getEnabledPersistentGenericServers()).toEqual([]);
});

it("honors a bearer-token env var for user-declared servers", () => {
Expand Down Expand Up @@ -175,7 +175,7 @@ describe("McpManager", () => {
}),
});

expect(manager.getEnabledGenericServers()).toEqual(["alpha", "zebra"]);
expect(manager.getEnabledPersistentGenericServers()).toEqual(["alpha", "zebra"]);
});

it("picks up mcpServers added after construction on refresh()", () => {
Expand Down Expand Up @@ -267,7 +267,7 @@ describe("McpManager", () => {
credentialSource: "acp",
});
await expect(handlers["mcp.refresh"]({ server: "task" })).rejects.toThrow("does not use host OAuth");
expect(manager.getEnabledGenericServers()).toContain("task");
expect(manager.getAcpServers().map((server) => server.name)).toContain("task");

expect(manager.replaceAcpServers([], "owner-b")).toBe(false);
expect(() =>
Expand Down
Loading
Loading