mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
feat(plugins): contribute launch-only MCP servers at session open
This commit is contained in:
@@ -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 () => {};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user