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