From e1684aece0974fb5c03fa6830c8a27bf813ff32e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 29 Oct 2026 08:58:25 +0100 Subject: [PATCH] fix(agents): make injected MCP launch requirements explicit Optional injected servers must survive persistence and reload without turning missing transport support into launch refusal. Required external and plugin servers keep their existing refusal. Advertise the optional MCP contract so older hosts retain established strict launch behavior. --- docs/providers.md | 13 ++- packages/protocol/src/agent-types.ts | 2 + packages/protocol/src/messages.ts | 2 + .../src/server/agent/agent-manager.test.ts | 103 ++++++++++-------- .../server/src/server/agent/agent-manager.ts | 12 +- .../src/server/agent/agent-projections.ts | 3 + .../src/server/agent/agent-sdk-types.ts | 1 + .../server/src/server/agent/agent-storage.ts | 5 + .../server/src/server/websocket-server.ts | 1 + 9 files changed, 91 insertions(+), 51 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 1a43558ae3..86ea7c0b8e 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) for native keys and examples. -Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail -closed for Hub unattended execution until it can approve one exact injected MCP -server and tool identity without approving native tools. +Exact MCP preapproval is a separate daemon-owned contract. A required tool policy +(the default) fails closed unless the provider can approve exact injected server/tool +identities without approving native tools. Native agent creation also accepts +`toolPolicy.enforcement: "if-supported"` for automatic approval convenience: +unsupported providers retain ordinary MCP configuration and native permissions. +Hub uses that optional request; MCP support and exact approval are not provider +eligibility requirements. Optional injection is declared separately with +`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers +(including plugin-owned servers) keep their required semantics. The daemon +advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only. ## Core adapter patterns diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 8a573b6434..c78abdbb9c 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -515,6 +515,8 @@ export interface ToolPolicy { } export interface AgentSessionConfig { + /** Per-server launch requirement; unspecified servers are required. */ + mcpServerRequirements?: Record; provider: AgentProvider; cwd: string; /** diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d853230848..5005c2d7a1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -494,6 +494,7 @@ const AgentSessionConfigSchema = z.object({ enforcement: z.enum(["required", "if-supported"]).optional(), }).optional(), systemPrompt: z.string().optional(), + mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(), mcpServers: z.record(z.string(), McpServerConfigSchema).optional(), }); @@ -3673,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z internalWorkspaces: z.boolean().optional(), backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), + optionalMcpSupport: z.boolean().optional(), connectedHosts: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), usageSources: z.boolean().optional(), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c3cb539786..3765263b9e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3822,60 +3822,71 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in expect(snapshot.config.mcpServers).toBeUndefined(); }); -test("createAgent preserves injected MCP when optional approval is unavailable", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); +test.each([true, false])( + "createAgent permits optional approval with MCP capability %s", + async (supportsMcp) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); - class CaptureClient extends TestAgentClient { - lastConfig: AgentSessionConfig | null = null; + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; - override async createSession(config: AgentSessionConfig): Promise { - this.lastConfig = config; - return new McpCapableTestAgentSession(config); + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config); + } } - } - const client = new CaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - idFactory: () => "00000000-0000-4000-8000-000000000104", - }); - - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - toolPolicy: { - enforcement: "if-supported", - preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }], + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, }, - mcpServers: { - paseo: { - type: "http", - url: "https://example.com/custom-paseo", + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent( + { + provider: "codex", + mcpServerRequirements: { paseo: "optional" }, + cwd: workdir, + toolPolicy: { + enforcement: "if-supported", + preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }], + }, + mcpServers: { + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, }, }, - }, - undefined, - { workspaceId: undefined }, - ); + undefined, + { workspaceId: undefined }, + ); - expect(snapshot.config.mcpServers).toEqual({ - paseo: { - type: "http", - url: "https://example.com/custom-paseo", - }, - }); - expect(snapshot.config.toolPolicy).toBeUndefined(); - expect(client.lastConfig?.toolPolicy).toBeUndefined(); - expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); -}); + expect(snapshot.config.mcpServers).toEqual({ + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, + }); + expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({ + paseo: "optional", + }); + await manager.reloadAgentSession(snapshot.id); + expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({ + paseo: "optional", + }); + expect(snapshot.config.toolPolicy).toBeUndefined(); + expect(client.lastConfig?.toolPolicy).toBeUndefined(); + expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); + }, +); test("createAgent fails when cwd does not exist", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b7d8e934b3..a0a1246c6a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { if (record.config.providerOptions != null) { config.providerOptions = record.config.providerOptions; } + if (record.config.mcpServerRequirements != null) + config.mcpServerRequirements = record.config.mcpServerRequirements; if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy; if (record.config.systemPrompt != null) { config.systemPrompt = record.config.systemPrompt; @@ -1686,7 +1688,7 @@ export class AgentManager { ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( - Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 && + this.hasRequiredExternalMcpServers(providerLaunchConfig) && existing.session.capabilities.supportsMcpServers !== true ) { throw this.unsupportedMcpError(providerLaunchConfig, launchContext); @@ -3888,13 +3890,19 @@ export class AgentManager { } } + private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean { + return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some( + (name) => config.mcpServerRequirements?.[name] !== "optional", + ); + } + private async requireExternalMcpSupport( session: AgentSession, launchConfig: AgentSessionConfig, launchContext: AgentLaunchContext, ): Promise { if ( - Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 || + !this.hasRequiredExternalMcpServers(launchConfig) || session.capabilities.supportsMcpServers === true ) { return; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index b388143d8e..f9cc908a76 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC if (config.systemPrompt) { serializable.systemPrompt = config.systemPrompt; } + if (config.mcpServerRequirements) { + serializable.mcpServerRequirements = config.mcpServerRequirements; + } if (config.mcpServers) { serializable.mcpServers = config.mcpServers; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 507cfbceb8..09caf34abc 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -607,6 +607,7 @@ export interface AgentSessionConfig { title?: string | null; providerOptions?: ProviderOptions; toolPolicy?: ToolPolicy; + mcpServerRequirements?: Record; mcpServers?: Record; /** * Internal agents are hidden from listings and don't trigger notifications. diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index a097bd7f82..d5e6a4a7ee 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z .nullable() .optional(), systemPrompt: z.string().nullable().optional(), + mcpServerRequirements: z + .record(z.string(), z.enum(["optional", "required"])) + .nullable() + .optional(), mcpServers: z.record(z.string(), z.any()).nullable().optional(), }) .nullable() @@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick< | "toolPolicy" | "systemPrompt" | "mcpServers" + | "mcpServerRequirements" >; export type StoredAgentRecord = z.infer; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b7024a638b..e84f02a715 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer { workspaceSortTimes: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, + optionalMcpSupport: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: true, // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the