diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 017b69bda5..56f0d0d78c 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -238,7 +238,11 @@ describe("plugin agent tools SDK boundary", () => { // @ts-expect-error labels are read-only request.labels.x = "changed"; if (request.labels.x !== "y") return; - return { ...request, tools: [...request.tools, tool] }; + // @ts-expect-error MCP servers use the same typed transport config as saved servers + request.mcpServers.invalid = { type: "http", url: 42 }; + return { ...request, tools: [...request.tools, tool], mcpServers: { + ...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" }, + } }; }); return () => {}; } diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index f3f2643a04..9486383072 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -41,6 +41,7 @@ export interface PluginSessionOpenRequest { readonly labels: Readonly>; tools: AgentToolDefinition[]; env: Record; + mcpServers: NonNullable; } export type PluginTurnOutcome = diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 1342576f7f..8cbdfc068e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -85,7 +85,11 @@ import { import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { + PASEO_MCP_SERVER_NAME, + stripInternalPaseoMcpServer, + withRuntimePaseoMcpServer, +} from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; @@ -559,18 +563,20 @@ interface AgentMetadataPatch { const SYSTEM_ERROR_PREFIX = "[System Error]"; -function attachPersistenceCwd( +function normalizePersistenceForConfig( handle: AgentPersistenceHandle | null, - cwd: string, + config: AgentSessionConfig, ): AgentPersistenceHandle | null { if (!handle) { return null; } + const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {}; return { ...handle, metadata: { - ...handle.metadata, - cwd, + ...metadata, + cwd: config.cwd, + ...(config.mcpServers ? { mcpServers: config.mcpServers } : {}), }, }; } @@ -1387,7 +1393,7 @@ export class AgentManager { const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, @@ -1515,7 +1521,7 @@ export class AgentManager { launchContext, currentResumeOptions, ); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, @@ -1578,6 +1584,7 @@ export class AgentManager { }, { config: providerLaunchConfig, storedConfig, launchContext }, ); + await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext); let handedToRegistration = false; try { const importedConfig = await this.normalizeConfig( @@ -1673,10 +1680,10 @@ export class AgentManager { ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( - Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 && existing.session.capabilities.supportsMcpServers !== true ) { - throw new Error(`Provider '${provider}' does not support MCP servers`); + throw this.unsupportedMcpError(providerLaunchConfig, launchContext); } let session: AgentSession | undefined; @@ -1695,7 +1702,7 @@ export class AgentManager { session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); this.assertAcceptingAgentRegistrations(); if (rehydrateFromDisk) { @@ -2921,7 +2928,10 @@ export class AgentManager { ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } : null); if (persistenceHandle) { - mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); + mutableAgent.persistence = normalizePersistenceForConfig( + persistenceHandle, + mutableAgent.config, + ); } this.logger.trace( { @@ -3865,16 +3875,23 @@ export class AgentManager { private async requireExternalMcpSupport( session: AgentSession, - storedConfig: AgentSessionConfig, + launchConfig: AgentSessionConfig, + launchContext: AgentLaunchContext, ): Promise { if ( - Object.keys(storedConfig.mcpServers ?? {}).length === 0 || + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 || session.capabilities.supportsMcpServers === true ) { return; } await this.closeUnregisteredSession(session); - throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); + throw this.unsupportedMcpError(launchConfig, launchContext); + } + + private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error { + const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))]; + const source = owners.length ? ` (plugin ${owners.join(", ")})` : ""; + return new Error(`Provider '${config.provider}' does not support MCP servers${source}`); } private async initializeAgentTimelineForRegister(params: { @@ -3960,9 +3977,9 @@ export class AgentManager { foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, - persistence: attachPersistenceCwd( + persistence: normalizePersistenceForConfig( options?.persistence ?? session.describePersistence(), - config.cwd, + config, ), historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, lastUserMessageAt: options?.lastUserMessageAt ?? null, @@ -4266,9 +4283,9 @@ export class AgentManager { newInfo.modeId !== agent.runtimeInfo?.modeId; agent.runtimeInfo = newInfo; if (!agent.persistence && newInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: newInfo.sessionId }, - agent.cwd, + agent.config, ); } // Emit state if runtimeInfo changed so clients get the updated model @@ -4626,9 +4643,9 @@ export class AgentManager { case "model_changed": agent.runtimeInfo = event.runtimeInfo; if (!agent.persistence && event.runtimeInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, - agent.cwd, + agent.config, ); } this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); @@ -4702,7 +4719,7 @@ export class AgentManager { private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { - agent.persistence = attachPersistenceCwd(handle, agent.cwd); + agent.persistence = normalizePersistenceForConfig(handle, agent.config); } } @@ -5519,6 +5536,8 @@ export class AgentManager { // A plugin changing during this open must still make the resulting session stale. const generation = this.sessionOpenGeneration; this.detachPluginTools(agentId); + let mcpServers: AgentSessionConfig["mcpServers"]; + let mcpServerOwners: Record | undefined; if (this.pluginLifecycle) { const request = { agentId, @@ -5531,14 +5550,20 @@ export class AgentManager { title: opening?.title ?? null, labels: opening?.labels ?? {}, tools: [], + mcpServers: {}, }; const transformed = await this.pluginLifecycle.before("agent.session_open", request); env = transformed.env; - if (request.purpose === "interactive") + if (request.purpose === "interactive") { this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); + mcpServers = transformed.mcpServers; + mcpServerOwners = transformed.mcpServerOwners; + } } const context: AgentLaunchContext = { agentId, + mcpServers, + mcpServerOwners, env: { ...env, PASEO_AGENT_ID: agentId, @@ -5572,7 +5597,26 @@ export class AgentManager { launchConfig: AgentSessionConfig, launchContext: AgentLaunchContext, ): AgentSessionConfig { - return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; + const savedServerNames = new Set( + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}), + ); + for (const name of Object.keys(launchContext.mcpServers ?? {})) { + const owner = launchContext.mcpServerOwners?.[name]; + if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) { + const existingOwner = savedServerNames.has(name) + ? "agent saved configuration" + : "Paseo internal MCP server"; + throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`); + } + } + const merged = + Object.keys(launchContext.mcpServers ?? {}).length > 0 + ? { + ...launchConfig, + mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers }, + } + : launchConfig; + return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged; } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6818e65445..507cfbceb8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -616,6 +616,9 @@ export interface AgentSessionConfig { } export interface AgentLaunchContext { + /** Launch-only plugin MCP servers and their owners; never persisted. */ + mcpServers?: Record; + mcpServerOwners?: Record; agentId?: string; env?: Record; /** diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 3131447098..3841201994 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1321,3 +1321,28 @@ test("plugin without a runtime safety capability uses baseline collection withou await rm(home, { recursive: true, force: true }); } }); + +test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const storedConfig = { provider: harness.registration.id, cwd: "/project" }; + const company = { type: "http", url: "https://tools.example.com/mcp" } as const; + try { + const imported = await client.importSession!( + { providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" }, + { config: { ...storedConfig, mcpServers: { company } }, storedConfig }, + ); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + config: expect.objectContaining({ mcpServers: { company } }), + }), + ); + expect(imported.config).toEqual(storedConfig); + await imported.session.close(); + } finally { + await registry.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 68e9778e43..570e339203 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1052,7 +1052,7 @@ class PluginAgentClient implements AgentClient { if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); return { session, - config: { ...context.config, provider: this.provider, cwd: input.cwd }, + config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd }, persistence, timeline: session.timelineHistory(), providerSubagentEvents: session.subagentHistory(), diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index af04e8021c..711638ce2f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -1,6 +1,6 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; -const PASEO_MCP_SERVER_NAME = "paseo"; +export const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 3228306297..ae241d5a30 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -4,14 +4,23 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; +import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0", - agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + agentClients: { + codex: createTestAgentClient("codex", { + supportsMcpServers: true, + beforeCreateSession: async (config) => { + launches.push(config); + }, + }), + }, mcpEnabled: false, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -47,6 +56,10 @@ test("the configuration example adds MCP servers and overrides Codex options whi company: { type: "http", url: "https://tools.example.com/mcp" }, }, }); + expect(launches[0].mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }); await client.archiveAgent(agent.id); } finally { await client.close(); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts index 14629aa15e..5b03bae09b 100644 --- a/packages/server/src/server/plugins/agent-tools.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "node:url"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -11,6 +11,7 @@ import { type TestAgentClientOptions, } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { importSessionFromPersistence } from "../agent/provider-session-import.js"; import { BuiltinPluginLoader } from "./builtin/index.js"; import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; import type { @@ -27,7 +28,7 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = const events = { interrupts: 0 }; const inner = createTestAgentClient("codex", { ...callbacks, - supportsMcpServers: true, + supportsMcpServers: callbacks.supportsMcpServers ?? true, onStartTurn: (prompt, config) => { turns.push({ prompt, launchCount: launches.length }); callbacks.onStartTurn?.(prompt, config); @@ -52,6 +53,13 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = launches.push({ config: config as AgentSessionConfig, context }); return inner.resumeSession(handle, config, context, options); }, + importSession: (input, context) => + importSessionFromPersistence({ + provider: "codex", + request: input, + context, + resumeSession: provider.resumeSession.bind(provider), + }), isAvailable: () => inner.isAvailable(), fetchCatalog: (options) => inner.fetchCatalog(options), }; @@ -750,3 +758,285 @@ test("the agent-tools example appends notes only for labeled agents", async () = await h.close(); } }, 60_000); + +const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; +const mcpSource = ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, + env: { ...request.env, OPEN_REASON: request.reason } }; + }); + return () => {}; +} +`; + +test.each([ + { native: false, serverNames: ["paseo", "company"] }, + { native: true, serverNames: ["company"] }, +])( + "session-open MCP servers reach launch but never saved configuration (native=$native)", + async ({ native, serverNames }) => { + const h = await setup(native, mcpSource); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(a.config.mcpServers?.company).toBeUndefined(); + const snapshot = (await h.client.fetchAgent(a.id))!.agent; + expect(snapshot.id).toBe(a.id); + expect(JSON.stringify(snapshot)).not.toContain("tools.example.com"); + expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create"); + expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames); + await h.daemon.daemon.agentManager.closeAgent(a.id); + const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), { + recursive: true, + }); + const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!; + const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8"); + expect(record).not.toContain("tools.example.com"); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => { + const source = reloadRpcSource + .replace(' if (request.labels.x !== "y") return;', "") + .replace( + "return { ...request, env:", + `return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`, + ); + const h = await setup(true, source); + try { + const a = await h.create({ x: "y" }); + const manager = h.daemon.daemon.agentManager; + await manager.closeAgent(a.id); + await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume"); + await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "imported-session", + cwd: h.directory, + workspaceId: "import-workspace", + labels: { x: "y" }, + }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import"); + expect(imported.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com"); + expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com"); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => { + const h = await setup(true, "export default function contribute() { return () => {}; }"); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await h.install("company-mcp", mcpSource); + await sendAndFinish(h, a.id, "after install"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + await h.client.disablePlugin("company-mcp"); + await sendAndFinish(h, a.id, "after disable"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await sendAndFinish(h, a.id, "stable"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin never interrupts a running turn", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup( + true, + "export default function contribute() { return () => {}; }", + undefined, + { beforeTurnComplete: async () => blocked }, + ); + try { + const a = await h.create(); + await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" }); + const manager = h.daemon.daemon.agentManager; + await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull(); + const turn = manager.getAgent(a.id)!.activeTurnId; + await h.install("company-mcp", mcpSource); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + await h.client.disablePlugin("company-mcp"); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + expect(h.events.interrupts).toBe(0); + release(); + await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test.each([ + { + target: "saved configuration", + saved: true, + name: "company", + other: false, + error: /company.*plugin tools-test.*agent saved configuration/, + }, + { + target: "saved Paseo name", + saved: true, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*agent saved configuration/, + }, + { + target: "Paseo internal server", + saved: false, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*Paseo internal MCP server/, + }, + { + target: "another plugin with identical config", + saved: false, + name: "company", + other: true, + error: /Plugin tools-test.*company.*plugin other-mcp/, + }, +])( + "session-open MCP collisions with $target name both owners", + async ({ saved, name, other, error }) => { + const h = await setup(true, mcpSource.replace("company:", name + ":")); + try { + if (other) await h.install("other-mcp", mcpSource); + await expect( + h.daemon.daemon.agentManager.createAgent( + { + provider: "codex", + cwd: h.directory, + ...(saved + ? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } } + : {}), + }, + undefined, + {}, + ), + ).rejects.toThrow(error); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => { + const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false }); + try { + await h.client.disablePlugin("tools-test"); + const a = await h.create(); + await h.client.enablePlugin("tools-test"); + await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow( + /does not support MCP servers.*plugin tools-test/, + ); + expect(a.config.mcpServers?.company).toBeUndefined(); + await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + const imported = h.daemon.daemon.agentManager.importProviderSession({ + provider: "codex", + providerHandleId: "unsupported-import", + cwd: h.directory, + workspaceId: "import-workspace", + }); + await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + } finally { + await h.close(); + } +}, 60_000); + +test("history opens ignore session-open MCP servers", async () => { + const h = await setup(true, mcpSource); + try { + const a = await h.create(); + await h.client.archiveAgent(a.id); + await h.install("other-mcp", mcpSource); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => { + const h = await setup(true, mcpSource); + try { + await h.install( + "z-extend", + ` +export default function contribute(server) { + server.before("agent.session_open", ({ request }) => { + if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); + if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); + return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; + }); + server.before("agent.session_open", ({ request }) => { + if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); + return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; + }); + server.before("agent.session_open", () => undefined); + return () => {}; +}`, + ); + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers).toEqual({ + company: companyServer, + extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } }, + additional: { type: "http", url: "https://additional.example.com/mcp" }, + }); + const contributedServers = h.launches.at(-1)!.config.mcpServers; + await h.install( + "zz-remove", + `export default function contribute(server) { + server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} })); + return () => {}; +}`, + ); + await sendAndFinish(h, a.id, "after removal"); + expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers); + expect(a.config.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP configs are validated before a provider launches", async () => { + const h = await setup( + true, + mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), + ); + try { + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index bafd1f9b94..fe86f6a754 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -96,6 +96,7 @@ test("session-open hooks reject changes to session identity instead of silently title: null, labels: {}, tools: [], + mcpServers: {}, }, paseo, ), @@ -120,12 +121,13 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( title: "Original", labels: {}, tools: [], + mcpServers: {}, env: {}, reason: "create", purpose: "interactive", }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env and tools"); + ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers"); }, ); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 2b379eb7f8..c43e113e38 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -27,6 +27,11 @@ export type PluginWireBeforeRequests = Omit }; +}; + export const lifecycleEventNames = [ "agent.created", "agent.closed", @@ -54,6 +59,7 @@ const beforeSchemas = { labels: z.record(z.string(), z.string()), tools: z.array(agentToolMetadataSchema), env: z.record(z.string(), z.string()), + mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), @@ -80,7 +86,7 @@ export interface PluginLifecycle { before( name: Name, request: PluginWireBeforeRequests[Name], - ): Promise; + ): Promise; } export function validateBeforeRequest( @@ -176,7 +182,7 @@ export function validateBeforeResult( name: Name, input: PluginWireBeforeRequests[Name], - ): Promise { + ): Promise { let request = validateBeforeRequest(name, input); const plugins = [...this.plugins.values()].sort((left, right) => { return left.id.localeCompare(right.id); }); const attached: AttachedAgentTool[] = []; + const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; + const mcpServerOwners: Record = {}; for (const loaded of plugins) { if (!loaded.hooks.before.includes(name)) { continue; @@ -587,11 +589,14 @@ export class PluginRuntime { requestId: randomUUID(), kind: "before", name, - input: name === "agent.session_open" ? { ...request, tools: [] } : request, + input: + name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request, }); request = validateBeforeResult(name, request, output); if (name === "agent.session_open") { - this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools); + const opened = request as SessionOpenWireRequest; + this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); + this.collectAgentTools(attached, loaded, opened.tools); } } catch (error) { if (name === "agent.session_open") @@ -601,7 +606,14 @@ export class PluginRuntime { }); } } - if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached; + if (name === "agent.session_open") { + return { + ...request, + tools: attached, + mcpServers, + mcpServerOwners, + } as PluginBeforeResults[Name]; + } return request; } @@ -623,6 +635,23 @@ export class PluginRuntime { return output; } + private collectMcpServers( + servers: SessionOpenWireRequest["mcpServers"], + owners: Record, + loaded: LoadedPlugin, + contributions: SessionOpenWireRequest["mcpServers"], + ): void { + for (const [name, server] of Object.entries(contributions)) { + if (Object.hasOwn(servers, name)) { + throw new Error( + `MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`, + ); + } + servers[name] = server; + owners[name] = loaded.id; + } + } + private collectAgentTools( attached: AttachedAgentTool[], loaded: LoadedPlugin,