Handle ACP providers without MCP support

This commit is contained in:
Mohamed Boudra
2026-06-10 11:29:26 +07:00
parent e9f9759ba8
commit b3f44981a8
11 changed files with 332 additions and 57 deletions
@@ -17,6 +17,7 @@ const mockState = vi.hoisted(() => {
cursor: [] as Array<{
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}>,
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
@@ -24,6 +25,7 @@ const mockState = vi.hoisted(() => {
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}>,
},
isCommandAvailable: vi.fn(async (_command: string) => false),
@@ -239,7 +241,7 @@ vi.mock("./providers/pi/agent.js", () => ({
vi.mock("./providers/generic-acp-agent.js", () => ({
GenericACPAgentClient: class GenericACPAgentClient {
readonly capabilities = {
capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
@@ -255,7 +257,21 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}) {
const providerParams =
options.providerParams &&
typeof options.providerParams === "object" &&
!Array.isArray(options.providerParams)
? (options.providerParams as Record<string, unknown>)
: {};
this.capabilities = {
...this.capabilities,
supportsMcpServers:
typeof providerParams.supportsMcpServers === "boolean"
? providerParams.supportsMcpServers
: this.capabilities.supportsMcpServers,
};
this.runtimeSettings = {
command: {
mode: "replace",
@@ -268,6 +284,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
});
}
@@ -306,7 +323,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
readonly provider = "acp";
readonly runtimeSettings?: unknown;
constructor(options: { command: string[]; env?: Record<string, string> }) {
constructor(options: {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = {
command: {
mode: "replace",
@@ -317,6 +338,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
mockState.constructorArgs.cursor.push({
command: options.command,
env: options.env,
providerParams: options.providerParams,
});
}
@@ -517,6 +539,7 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
},
providerId: "my-agent",
label: "My Agent",
providerParams: undefined,
},
{
command: ["my-agent", "--acp"],
@@ -525,6 +548,46 @@ test("new provider extending acp uses GenericACPAgentClient", () => {
},
providerId: "my-agent",
label: "My Agent",
providerParams: undefined,
},
]);
});
test("ACP provider params can disable MCP support", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
"no-mcp-acp": {
extends: "acp",
label: "No MCP ACP",
command: ["no-mcp-acp", "serve"],
params: {
supportsMcpServers: false,
},
},
},
});
const client = registry["no-mcp-acp"].createClient(logger);
expect(client.capabilities.supportsMcpServers).toBe(false);
expect(mockState.constructorArgs.genericAcp).toEqual([
{
command: ["no-mcp-acp", "serve"],
env: undefined,
providerId: "no-mcp-acp",
label: "No MCP ACP",
providerParams: {
supportsMcpServers: false,
},
},
{
command: ["no-mcp-acp", "serve"],
env: undefined,
providerId: "no-mcp-acp",
label: "No MCP ACP",
providerParams: {
supportsMcpServers: false,
},
},
]);
});
@@ -550,12 +613,14 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
env: {
CURSOR_AGENT_LOG: "debug",
},
providerParams: undefined,
},
{
command: ["cursor-agent", "acp"],
env: {
CURSOR_AGENT_LOG: "debug",
},
providerParams: undefined,
},
]);
expect(mockState.constructorArgs.genericAcp).toEqual([]);
@@ -602,6 +602,7 @@ function addDerivedProviders(
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
})
: new GenericACPAgentClient({
logger,
@@ -609,6 +610,7 @@ function addDerivedProviders(
env: override.env,
providerId,
label: override.label ?? providerId,
providerParams: override.params,
}),
});
continue;
@@ -26,6 +26,7 @@ import {
mapACPUsage,
resolveACPModeSelection,
resolveACPModelSelection,
summarizeACPRequestError,
} from "./acp-agent.js";
import {
COPILOT_ALLOW_ALL_MODE_ID,
@@ -48,6 +49,7 @@ interface ACPSessionInternals {
activeForegroundTurnId: string | null;
configOptions: SessionConfigOption[];
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
acpMcpServers(): unknown[];
}
interface ACPModelSelectionInternals {
@@ -1514,6 +1516,52 @@ describe("ACPAgentSession slash commands", () => {
});
describe("ACPAgentSession", () => {
test("drops MCP servers from ACP requests when the provider does not support MCP", () => {
const session = new ACPAgentSession(
{
provider: "no-mcp-acp",
cwd: "/tmp/paseo-acp-test",
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
},
},
{
provider: "no-mcp-acp",
logger: createTestLogger(),
defaultCommand: ["no-mcp-acp", "serve"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
expect(asInternals<ACPSessionInternals>(session).acpMcpServers()).toEqual([]);
});
test("summarizes JSON-RPC error details without stringifying objects", () => {
const summary = summarizeACPRequestError(
new RequestError(-32603, "Internal error", {
details: "Droid process exited unexpectedly (exit code 1)",
}),
);
expect(summary).toMatchObject({
message: "Internal error: Droid process exited unexpectedly (exit code 1)",
code: "-32603",
});
expect(summary.message).not.toContain("[object Object]");
expect(summary.diagnostic).toContain("Droid process exited unexpectedly");
});
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
const logger = createTestLogger();
const trace = vi.spyOn(logger, "trace");
@@ -112,7 +112,22 @@ function isACPError(value: unknown): value is ACPError {
return isRecord(value) && typeof value.message === "string" && typeof value.code === "number";
}
function summarizeACPRequestError(error: unknown): {
function extractACPErrorDataMessage(data: unknown): string | null {
if (!isRecord(data)) {
return null;
}
for (const key of ["details", "errorMessage", "message", "detail", "title"]) {
const value = data[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return extractACPErrorDataMessage(data.error);
}
export function summarizeACPRequestError(error: unknown): {
message: string;
code?: string;
diagnostic?: string;
@@ -120,11 +135,14 @@ function summarizeACPRequestError(error: unknown): {
// Promise rejections are untyped, but the ACP SDK rejects JSON-RPC failures as response.error.
if (isACPError(error)) {
const code = String(error.code);
const detail = extractACPErrorDataMessage(error.data);
const message =
detail && detail !== error.message ? `${error.message}: ${detail}` : error.message;
const data = error.data === undefined ? "" : ` | data=${JSON.stringify(error.data)}`;
return {
message: error.message,
message,
code,
diagnostic: `${error.message} | code=${code}${data}`,
diagnostic: `${message} | code=${code}${data}`,
};
}
@@ -135,6 +153,17 @@ function summarizeACPRequestError(error: unknown): {
return { message: String(error) };
}
function toACPRequestError(error: unknown): Error {
if (!isACPError(error)) {
return error instanceof Error ? error : new Error(String(error));
}
const summary = summarizeACPRequestError(error);
const next = new Error(summary.message);
next.name = "ACPRequestError";
return next;
}
function resolveTerminalCommand(
command: string,
args?: string[],
@@ -151,7 +180,7 @@ function resolveTerminalCommand(
return { command: shell.command, args: [...shell.flag, command] };
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
@@ -673,10 +702,12 @@ export class ACPAgentClient implements AgentClient {
const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV);
try {
const response = await probe.connection.newSession({
cwd,
mcpServers: [],
});
const response = await this.runACPRequest(() =>
probe.connection.newSession({
cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
const models = deriveModelDefinitionsFromACP(
this.provider,
@@ -693,10 +724,12 @@ export class ACPAgentClient implements AgentClient {
const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV);
try {
const response = await probe.connection.newSession({
cwd,
mcpServers: [],
});
const response = await this.runACPRequest(() =>
probe.connection.newSession({
cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
const modeInfo = deriveModesFromACP(
this.defaultModes,
@@ -721,8 +754,8 @@ export class ACPAgentClient implements AgentClient {
const sessions: PersistedAgentDescriptor[] = [];
let cursor: string | null | undefined;
for (;;) {
const page: ListSessionsResponse = await probe.connection.listSessions(
cursor ? { cursor } : {},
const page: ListSessionsResponse = await this.runACPRequest(() =>
probe.connection.listSessions(cursor ? { cursor } : {}),
);
for (const session of page.sessions) {
sessions.push({
@@ -809,15 +842,17 @@ export class ACPAgentClient implements AgentClient {
let initialize: InitializeResponse;
try {
initialize = await Promise.race([
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnErrorPromise,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]);
initialize = await this.runACPRequest(() =>
Promise.race([
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnErrorPromise,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
} catch (error) {
await terminateChildProcess(child, 2_000);
throw error;
@@ -861,6 +896,14 @@ export class ACPAgentClient implements AgentClient {
}
}
protected async runACPRequest<T>(request: () => Promise<T>): Promise<T> {
try {
return await request();
} catch (error) {
throw toACPRequestError(error);
}
}
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const prefix = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
@@ -998,10 +1041,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.connection = spawned.connection;
this.agentCapabilities = spawned.initialize.agentCapabilities ?? null;
const response = await this.connection.newSession({
cwd: this.config.cwd,
mcpServers: normalizeMcpServers(this.config.mcpServers),
});
const response = await this.runACPRequest(() =>
this.connection!.newSession({
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.sessionId = response.sessionId;
this.bootstrapThreadEventPending = true;
this.applySessionState(response);
@@ -1024,20 +1069,24 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const sessionCapabilities = this.agentCapabilities?.sessionCapabilities;
if (this.agentCapabilities?.loadSession) {
this.replayingHistory = true;
const response = await this.connection.loadSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: normalizeMcpServers(this.config.mcpServers),
});
const response = await this.runACPRequest(() =>
this.connection!.loadSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.replayingHistory = false;
this.historyPending = this.persistedHistory.length > 0;
this.applySessionState(response);
} else if (sessionCapabilities?.resume) {
const response = await this.connection.unstable_resumeSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: normalizeMcpServers(this.config.mcpServers),
});
const response = await this.runACPRequest(() =>
this.connection!.unstable_resumeSession({
sessionId: handle.sessionId,
cwd: this.config.cwd,
mcpServers: this.acpMcpServers(),
}),
);
this.applySessionState(response);
} else {
throw new Error(`${this.provider} does not support ACP session resume`);
@@ -1853,15 +1902,29 @@ export class ACPAgentSession implements AgentSession, ACPClient {
{ logger: this.logger, provider: this.provider },
);
const connection = new ClientSideConnection(() => this, stream);
const initialize = await connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientInfo: { name: "Paseo", version: "dev" },
});
const initialize = await this.runACPRequest(() =>
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientInfo: { name: "Paseo", version: "dev" },
}),
);
return { child, connection, initialize };
}
private async runACPRequest<T>(request: () => Promise<T>): Promise<T> {
try {
return await request();
} catch (error) {
throw toACPRequestError(error);
}
}
private acpMcpServers(): McpServer[] {
return this.capabilities.supportsMcpServers ? normalizeMcpServers(this.config.mcpServers) : [];
}
private applySessionState(response: SessionStateResponse): void {
const transformed = this.sessionResponseTransformer
? this.sessionResponseTransformer(response)
@@ -11,6 +11,7 @@ interface CursorACPAgentClientOptions {
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
@@ -28,6 +29,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
// cursor-agent publishes slash commands asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
@@ -7,6 +7,17 @@ const mockState = vi.hoisted(() => ({
}));
vi.mock("./acp-agent.js", () => ({
DEFAULT_ACP_CAPABILITIES: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
ACPAgentClient: class ACPAgentClient {
readonly provider: string;
@@ -40,7 +51,35 @@ describe("GenericACPAgentClient", () => {
},
},
defaultCommand: ["hermes", "acp"],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
},
]);
});
test("uses provider params to report MCP support", () => {
const _client = new GenericACPAgentClient({
logger: createTestLogger(),
command: ["no-mcp-acp", "serve"],
providerParams: {
supportsMcpServers: false,
},
});
void _client;
expect(mockState.superConstructorOptions.at(-1)).toMatchObject({
capabilities: {
supportsMcpServers: false,
},
});
});
});
@@ -1,10 +1,12 @@
import { homedir } from "node:os";
import type { Logger } from "pino";
import { z } from "zod";
import type { AgentProvider } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags, AgentProvider } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import {
ACPAgentClient,
DEFAULT_ACP_CAPABILITIES,
deriveModelDefinitionsFromACP,
deriveModesFromACP,
type SessionStateResponse,
@@ -20,12 +22,21 @@ import {
const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000;
const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000;
export const GenericACPProviderParamsSchema = z
.object({
supportsMcpServers: z.boolean().optional(),
})
.passthrough();
type GenericACPProviderParams = z.infer<typeof GenericACPProviderParamsSchema>;
interface GenericACPAgentClientOptions {
logger: Logger;
command: [string, ...string[]];
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
}
@@ -43,6 +54,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
env: options.env,
},
defaultCommand: options.command,
capabilities: buildGenericACPCapabilities(options),
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
});
@@ -173,6 +185,18 @@ export class GenericACPAgentClient extends ACPAgentClient {
}
}
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {
const params = parseGenericACPProviderParams(options.providerParams);
return {
...DEFAULT_ACP_CAPABILITIES,
supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers,
};
}
function parseGenericACPProviderParams(params: unknown): GenericACPProviderParams {
return GenericACPProviderParamsSchema.parse(params ?? {});
}
interface ACPDiagnosticProbeResult {
status: string;
initialize: string;