feat(plugins): contribute launch-only MCP servers at session open

This commit is contained in:
Mohamed Boudra
2026-10-27 03:46:36 +01:00
parent 10bc10c99f
commit 837d96ad9a
12 changed files with 458 additions and 39 deletions
+5 -1
View File
@@ -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 () => {};
}
+1
View File
@@ -41,6 +41,7 @@ export interface PluginSessionOpenRequest {
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
export type PluginTurnOutcome =
@@ -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<void> {
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<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
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<string, string> | 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<AgentClient> {
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
agentId?: string;
env?: Record<string, string>;
/**
@@ -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();
}
});
@@ -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(),
@@ -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 {
@@ -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();
@@ -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<void>((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);
@@ -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");
},
);
@@ -27,6 +27,11 @@ export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session
"agent.session_open": SessionOpenWireRequest;
};
/** Daemon-only results; ownership never crosses the plugin process boundary. */
export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
};
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 extends keyof PluginWireBeforeRequests>(
name: Name,
request: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]>;
): Promise<PluginBeforeResults[Name]>;
}
export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
@@ -176,7 +182,7 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels)
) {
throw new Error("agent.session_open hooks can only change env and tools");
throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
}
}
if (name === "agent.create") {
@@ -290,8 +296,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
tools = local.tools;
}
context.signal.throwIfAborted();
if (request.purpose === "history") request.tools = [];
else this.agentTools?.attach(request.agentId, tools);
if (request.purpose === "history") {
request.tools = [];
request.mcpServers = {};
} else this.agentTools?.attach(request.agentId, tools);
return request;
}
+34 -5
View File
@@ -1,4 +1,4 @@
import type { PluginWireBeforeRequests } from "./lifecycle/index.js";
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage";
import {
@@ -571,12 +571,14 @@ export class PluginRuntime {
async before<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]> {
): Promise<PluginBeforeResults[Name]> {
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<string, string> = {};
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<string, string>,
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,