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
+32 -12
View File
@@ -438,6 +438,25 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"params": {
"supportsMcpServers": false
}
}
}
}
}
```
### Generic ACP diagnostics
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
@@ -574,18 +593,19 @@ When an `additionalModels` entry has the same `id` as a discovered model, it upd
Every entry under `agents.providers` accepts these fields:
| Field | Type | Required | Description |
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
| Field | Type | Required | Description |
| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ |
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
| `label` | `string` | Yes (custom only) | Display name in the UI |
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
### Model definition
@@ -9,6 +9,7 @@ export interface AcpProviderCatalogEntry {
installLink: string;
command: readonly [string, ...string[]];
env?: Readonly<Record<string, string>>;
params?: Readonly<Record<string, unknown>>;
}
const CATALOG_DATA = [
@@ -166,6 +167,7 @@ const CATALOG_DATA = [
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
},
params: { supportsMcpServers: false },
},
{
id: "fast-agent",
@@ -357,5 +359,6 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map(
installLink: entry.installLink,
command: entry.command,
env: "env" in entry ? entry.env : undefined,
params: "params" in entry ? entry.params : undefined,
iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null,
}));
@@ -66,4 +66,12 @@ describe("ACP provider catalog", () => {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
});
});
it("preserves provider params in the daemon config patch", () => {
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
expect(droidPatch.providers?.["factory-droid"]?.params).toEqual({
supportsMcpServers: false,
});
});
});
@@ -19,6 +19,7 @@ export function buildAcpProviderConfigPatch(
description: entry.description,
command: [...entry.command],
env: entry.env ? { ...entry.env } : {},
...(entry.params ? { params: { ...entry.params } } : {}),
},
},
};
@@ -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;