Skip to content
Merged
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/1378-acp-mcp-programs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added session-scoped ACP MCP servers through the kernel MCP program API ([#1378](https://github.com/PrimeIntellect-ai/prime-agent/pull/1378) by [@hallerite](https://github.com/hallerite)).
24 changes: 24 additions & 0 deletions packages/coding-agent/docs/acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ One session per connection is a deliberate limit: Prime Agent's underlying sessi

Likewise `session/prompt` refuses a concurrent turn while one is running, and the working directory cannot be changed after startup — a client-supplied `cwd` that differs from the agent's real one is reported back in `_meta` rather than silently ignored.

## MCP servers

Prime Agent accepts standard stdio and HTTP servers in `session/new.mcpServers`. The servers are
available through the pre-imported `mcp` Python program for that ACP session:

```python
tools = await mcp.list_tools("task-tools")
result = await mcp.call_tool("task-tools", "lookup", {"query": "example"})
```

HTTP requests use only the URL and headers supplied by the ACP client. They do not read
`auth.json`, start or refresh Prime Agent OAuth, or modify persistent MCP settings. Stdio servers
run with the agent's actual session cwd, the supplied command and arguments, a scrubbed base
environment, and the exact environment values supplied by the ACP client.

The configuration is removed when the ACP session closes or the client disconnects. A same-named
persistent MCP server can therefore be shadowed for the ACP session without sending its stored
OAuth credential to the client-supplied HTTP endpoint. Daemon-backed configuration is bound to the
ACP connection that installed it, so another attached client cannot replace or clear it.

ACP stdio is a trusted-code boundary, not a sandbox. The requested command runs as the Prime Agent
user and can access any files that user can access, including credential stores. Only accept stdio
servers from trusted ACP clients or run Prime Agent inside an appropriate sandbox.

## Streamed updates

Session activity arrives as `session/update` notifications:
Expand Down
28 changes: 28 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import {
} from "./goals.js";
import type { HostRequestHandlers, KernelSentAgentMessage } from "./kernel/index.js";
import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js";
import type { AcpMcpServerConfig } from "./mcp/acp-mcp-types.js";
import type { McpManager } from "./mcp/mcp-manager.js";
import {
type BashExecutionMessage,
Expand Down Expand Up @@ -1303,6 +1304,33 @@ export class AgentSession {
this.agent.state.systemPrompt = this._baseSystemPrompt;
}

replaceAcpMcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): void {
if (this.isStreaming) throw new Error("Cannot replace ACP MCP servers while the agent is running");
if (!this._mcpManager) {
if (servers.length > 0) throw new Error("MCP is unavailable in this session");
return;
}
if (!this._mcpManager.replaceAcpServers(servers, ownerId)) return;
this._rebuildRuntimeForAcpMcpServers();
}

releaseAcpMcpServers(ownerId: string): void {
if (!this._mcpManager?.replaceAcpServers([], ownerId)) return;
// Host MCP handlers read this manager dynamically, so credentials disappear
// immediately without rebuilding a kernel that may still be running.
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this.agent.state.systemPrompt = this._baseSystemPrompt;
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

private _rebuildRuntimeForAcpMcpServers(): void {
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
includeAllExtensionTools: true,
});
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this.agent.state.systemPrompt = this._baseSystemPrompt;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

get modelRegistry(): ModelRegistry {
return this._modelRegistry;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/coding-agent/src/core/mcp/acp-mcp-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type AcpMcpServerConfig =
| {
name: string;
type: "http";
url: string;
headers: Record<string, string>;
}
| {
name: string;
type: "stdio";
command: string;
args: string[];
cwd: string;
env: Record<string, string>;
};
41 changes: 37 additions & 4 deletions packages/coding-agent/src/core/mcp/mcp-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { AuthStorage } from "../auth-storage.js";
import type { McpServerConfig } from "../settings-manager.js";
import type { AcpMcpServerConfig } from "./acp-mcp-types.js";

export interface McpManagerOptions {
authStorage: AuthStorage;
Expand All @@ -36,6 +37,8 @@ export class McpManager {
private readonly getUserServers: () => Record<string, McpServerConfig> | undefined;
private readonly beginLogin?: (server: string) => Promise<void>;
private integrations = new Map<string, ResolvedIntegration>();
private acpServers = new Map<string, AcpMcpServerConfig>();
private acpOwnerId?: string;
/** Provider ids we registered for user servers, so refresh can drop removed ones. */
private registeredUserProviderIds = new Set<string>();

Expand All @@ -53,6 +56,29 @@ export class McpManager {
this.registerProviders();
}

replaceAcpServers(servers: readonly AcpMcpServerConfig[], ownerId: string): boolean {
if (!ownerId) throw new Error("ACP MCP owner id is required");
if (servers.length === 0 && this.acpOwnerId !== ownerId) return false;
if (servers.length > 0 && this.acpOwnerId && this.acpOwnerId !== ownerId) {
throw new Error("ACP MCP configuration is owned by another client");
}
Comment thread
sethkarten marked this conversation as resolved.
Resolved

const next = new Map<string, AcpMcpServerConfig>();
for (const server of servers) {
if (next.has(server.name)) throw new Error(`Duplicate ACP MCP server: ${server.name}`);
next.set(server.name, server);
}
const unchanged =
next.size === this.acpServers.size &&
Array.from(next).every(
([name, config]) => JSON.stringify(this.acpServers.get(name)) === JSON.stringify(config),
);
if (unchanged) return false;
this.acpServers = next;
this.acpOwnerId = next.size > 0 ? ownerId : undefined;
return true;
}

private providerId(server: string): string {
return `mcp:${server}`;
}
Expand Down Expand Up @@ -151,6 +177,7 @@ export class McpManager {
"mcp.refresh": async (payload) => {
const server = String(payload.server ?? "");
if (!server) throw new Error("mcp.refresh requires a server");
if (this.acpServers.has(server)) throw new Error(`ACP MCP server ${server} does not use host OAuth`);
// getApiKey refreshes + rewrites auth.json under lock; Python re-reads.
// Surface failure (throw) instead of a false success so the kernel can
// report a refresh error rather than a misleading "not enabled".
Expand All @@ -163,6 +190,11 @@ export class McpManager {
"mcp.config": async (payload) => {
const server = String(payload.server ?? "");
if (!server) throw new Error("mcp.config requires a server");
const acpServer = this.acpServers.get(server);
if (acpServer) {
const { name: _name, ...config } = acpServer;
return { ...config, credentialSource: "acp" };
}
const integration = this.integrations.get(server);
if (!integration?.userDeclared || getCatalogEntry(server)) return {};
return { ...integration.config };
Expand All @@ -182,18 +214,19 @@ export class McpManager {
return handlers;
}

/** Enabled user servers available through the generic kernel API. */
/** Enabled persistent and session-scoped servers available through the generic kernel API. */
getEnabledGenericServers(): string[] {
return Array.from(this.integrations.values())
const servers = 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)
.sort((left, right) => left.localeCompare(right));
.map((integration) => integration.server);
for (const server of this.acpServers.keys()) servers.push(server);
return [...new Set(servers)].sort((left, right) => left.localeCompare(right));
}

/** Status for the /mcp list command. */
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ function formatGenericMcpGuidance(servers: string[] | undefined): string {
"# Generic MCP Connections",
"",
"Generic MCP connections are accessed through the pre-imported Python `mcp` object in IPython, not as top-level native tool namespaces or installed Python skills.",
`Enabled user-configured generic MCP servers: ${enabledServers.map((server) => `\`${server}\``).join(", ")}.`,
`Enabled generic MCP servers: ${enabledServers.map((server) => `\`${server}\``).join(", ")}.`,
...enabledServers.map(
(server) =>
`For \`${server}\`, first discover its tools with \`await mcp.list_tools("${server}")\`, then call one with \`await mcp.call_tool("${server}", "<tool>", arguments)\`.`,
Expand Down
95 changes: 95 additions & 0 deletions packages/coding-agent/src/modes/acp/acp-mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { validateHeaderName, validateHeaderValue } from "node:http";
import type { McpServer } from "@agentclientprotocol/sdk";
import { RequestError } from "@agentclientprotocol/sdk";
import type { AcpMcpServerConfig } from "../../core/mcp/acp-mcp-types.js";

const SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;

function entries(
server: string,
label: "header" | "environment",
values: readonly { name: string; value: string }[],
): Record<string, string> {
const result = Object.create(null) as Record<string, string>;
const seen = new Set<string>();
for (const entry of values) {
if (!entry.name) {
throw RequestError.invalidParams({ reason: `MCP server ${server} has an empty ${label} name` });
}
const identity = label === "header" ? entry.name.toLowerCase() : entry.name;
if (seen.has(identity)) {
throw RequestError.invalidParams({
reason: `MCP server ${server} has duplicate ${label} ${entry.name}`,
});
}
if (label === "header") {
try {
validateHeaderName(entry.name);
validateHeaderValue(entry.name, entry.value);
} catch {
throw RequestError.invalidParams({ reason: `MCP server ${server} has an invalid HTTP header` });
}
} else if (entry.name.includes("=") || entry.name.includes("\0") || entry.value.includes("\0")) {
throw RequestError.invalidParams({ reason: `MCP server ${server} has an invalid environment entry` });
}
seen.add(identity);
result[entry.name] = entry.value;
}
return result;
}

export function resolveAcpMcpServers(servers: readonly McpServer[], cwd: string): AcpMcpServerConfig[] {
const names = new Set<string>();
return servers.map((server) => {
if (!SERVER_NAME_PATTERN.test(server.name)) {
throw RequestError.invalidParams({
reason:
"MCP server names must start with an alphanumeric character and contain at most 64 alphanumeric, underscore, or hyphen characters",
});
}
if (names.has(server.name)) {
throw RequestError.invalidParams({ reason: `duplicate MCP server name: ${server.name}` });
}
names.add(server.name);

if ("command" in server) {
if (!server.command) {
throw RequestError.invalidParams({ reason: `MCP server ${server.name} has no stdio command` });
}
if (server.command.includes("\0") || server.args.some((argument) => argument.includes("\0"))) {
throw RequestError.invalidParams({ reason: `MCP server ${server.name} has an invalid stdio command` });
}
return {
name: server.name,
type: "stdio",
command: server.command,
args: [...server.args],
cwd,
env: entries(server.name, "environment", server.env),
};
}

if (server.type !== "http") {
throw RequestError.invalidParams({
reason: `MCP server ${server.name} uses unsupported ${server.type} transport`,
});
}
let url: URL;
try {
url = new URL(server.url);
} catch {
throw RequestError.invalidParams({ reason: `MCP server ${server.name} has an invalid HTTP URL` });
}
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
throw RequestError.invalidParams({
reason: `MCP server ${server.name} must use an HTTP(S) URL without embedded credentials`,
});
}
return {
name: server.name,
type: "http",
url: url.toString(),
headers: entries(server.name, "header", server.headers),
};
});
}
Loading
Loading