mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Let plugins contribute MCP servers on every agent session open (#7983)
* feat(plugins): contribute launch-only MCP servers at session open * docs(plugins): explain saved configuration and launch-only MCP servers
This commit is contained in:
+5
-1
@@ -430,7 +430,11 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
|
||||
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
|
||||
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
|
||||
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
|
||||
re-derived on each open and following the plugin's lifecycle. See
|
||||
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
|
||||
`plugin-examples/lifecycle-logger` registers all
|
||||
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
|
||||
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
|
||||
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
|
||||
|
||||
@@ -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,17 +4,29 @@ 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 () => {
|
||||
test("the configuration example saves Codex options and injects launch-only MCP servers 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 }) },
|
||||
daemonVersion: "0.13.2",
|
||||
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" });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.13.2",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
@@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi
|
||||
},
|
||||
mcpServers: {
|
||||
existing: { type: "http", url: "https://existing.example.com/mcp" },
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
},
|
||||
});
|
||||
expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({
|
||||
existing: { type: "http", url: "https://existing.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 {
|
||||
@@ -535,12 +535,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;
|
||||
@@ -551,11 +553,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")
|
||||
@@ -565,7 +570,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;
|
||||
}
|
||||
|
||||
@@ -587,6 +599,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,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
# Agent configuration
|
||||
|
||||
Changes new Codex agents through `server.before("agent.create", ...)`:
|
||||
Sets saved Codex options through `server.before("agent.create", ...)`:
|
||||
|
||||
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
|
||||
- Sets `config.providerOptions.approval_policy` to `on-request`.
|
||||
- Adds the `company` server to `config.mcpServers`.
|
||||
- Preserves other configuration, provider options, MCP servers, and environment overrides.
|
||||
- Preserves other configuration and provider options.
|
||||
|
||||
Adds the `company` MCP server to Codex launches through
|
||||
`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration;
|
||||
existing saved MCP servers are preserved. Existing agents receive the server through the
|
||||
[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents).
|
||||
|
||||
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
|
||||
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name.
|
||||
this example. A `company` name collision with a saved server or another
|
||||
plugin fails the session open and names both sources.
|
||||
|
||||
Install on a daemon with plugins enabled:
|
||||
|
||||
@@ -16,11 +21,13 @@ Install on a daemon with plugins enabled:
|
||||
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
|
||||
```
|
||||
|
||||
The callback receives the request after earlier plugins have transformed it. The nested spreads
|
||||
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves
|
||||
the resulting configuration before starting the agent. Existing agents are unchanged.
|
||||
Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve
|
||||
entries added by this plugin's earlier callbacks. The create callback receives configuration after
|
||||
earlier plugins have transformed it; its explicit Codex options replace the named fields in the
|
||||
saved configuration of new agents and remain after this plugin is disabled.
|
||||
|
||||
See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering
|
||||
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and
|
||||
environment injection. Its provider-switch callback clears provider options when changing Codex to
|
||||
Claude, so use these examples separately when trying the Codex-specific settings.
|
||||
See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing
|
||||
between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions)
|
||||
demonstrates provider switching and environment injection. Its provider-switch callback clears
|
||||
provider options when changing Codex to Claude, so use these examples separately when trying the
|
||||
Codex-specific settings.
|
||||
|
||||
@@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) {
|
||||
sandbox_mode: "workspace-write",
|
||||
approval_policy: "on-request",
|
||||
},
|
||||
mcpServers: {
|
||||
...request.config.mcpServers,
|
||||
company: {
|
||||
type: "http",
|
||||
url: "https://tools.example.com/mcp",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "codex") {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
mcpServers: {
|
||||
...request.mcpServers,
|
||||
company: {
|
||||
type: "http",
|
||||
url: "https://tools.example.com/mcp",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "agent-configuration",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
"paseo": ">=0.13.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,9 +653,14 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
Hooks run on the daemon while the plugin is enabled, even with no app connected.
|
||||
|
||||
### Change configuration and inject an MCP server
|
||||
`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain
|
||||
after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`,
|
||||
and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's
|
||||
lifecycle without being saved in the agent record.
|
||||
|
||||
Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint.
|
||||
### Change saved configuration
|
||||
|
||||
Add this callback inside `contribute(server)` to set the saved options for new Codex agents:
|
||||
|
||||
```ts
|
||||
server.before("agent.create", ({ request }) => {
|
||||
@@ -672,27 +677,50 @@ server.before("agent.create", ({ request }) => {
|
||||
sandbox_mode: "workspace-write",
|
||||
approval_policy: "on-request",
|
||||
},
|
||||
mcpServers: {
|
||||
...request.config.mcpServers,
|
||||
company: {
|
||||
type: "http",
|
||||
url: "https://tools.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
| Input | Result |
|
||||
| ------------------------------------------- | ----------------------------- |
|
||||
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
|
||||
| `providerOptions.web_search: "disabled"` | Preserved by the spread |
|
||||
| Existing `mcpServers.search` | Preserved by the spread |
|
||||
| Existing `mcpServers.company` | Replaced with the entry above |
|
||||
| Input | Result |
|
||||
| ------------------------------------------- | ----------------------- |
|
||||
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
|
||||
| `providerOptions.web_search: "disabled"` | Preserved by the spread |
|
||||
|
||||
The selected provider validates `providerOptions` and must support the configured MCP servers.
|
||||
Explicit Codex sandbox and approval options override its mode presets.
|
||||
The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options
|
||||
override its mode presets.
|
||||
|
||||
### Inject MCP servers
|
||||
|
||||
Use `agent.create.config.mcpServers` when the server should become part of the agent's saved
|
||||
configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing
|
||||
the placeholder URL with your MCP endpoint:
|
||||
|
||||
```ts
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "codex") return;
|
||||
|
||||
return {
|
||||
...request,
|
||||
mcpServers: {
|
||||
...request.mcpServers,
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`.
|
||||
The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never
|
||||
contains the agent's saved servers or another plugin's servers. Replace the map to add, replace,
|
||||
or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins'
|
||||
final maps in plugin-ID order.
|
||||
|
||||
A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server
|
||||
fails the session open and names the server and both sources. A provider without MCP support fails
|
||||
with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore
|
||||
these servers. Install, disable, and reload changes reach existing agents through the
|
||||
[session reopen behavior](#attach-tools-to-agents).
|
||||
|
||||
### Inject environment variables on every session opening
|
||||
|
||||
@@ -708,8 +736,8 @@ server.before("agent.session_open", ({ request }) => {
|
||||
});
|
||||
```
|
||||
|
||||
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an
|
||||
`agent.create` callback instead.
|
||||
Session-open overrides are re-derived on every open. `agent.create.env` applies only to the
|
||||
initial launch and is not saved in the agent's configuration.
|
||||
|
||||
### Attach tools to agents
|
||||
|
||||
@@ -992,11 +1020,11 @@ type PluginTurnOutcome =
|
||||
|
||||
### Before hooks
|
||||
|
||||
| Name | Request fields | Editable |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
|
||||
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools` | `env` and `tools` |
|
||||
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
|
||||
| Name | Request fields | Editable |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
|
||||
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
|
||||
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` |
|
||||
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
|
||||
|
||||
**`agent.create.config`** uses `AgentSessionConfig`:
|
||||
|
||||
@@ -1020,6 +1048,7 @@ type PluginTurnOutcome =
|
||||
"title": "Workspace notes",
|
||||
"labels": { "notes/enabled": "true" },
|
||||
"tools": [],
|
||||
"mcpServers": {},
|
||||
"reason": "resume",
|
||||
"purpose": "interactive",
|
||||
"env": { "COMPANY_ENV": "development" }
|
||||
@@ -1032,6 +1061,7 @@ type PluginTurnOutcome =
|
||||
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
|
||||
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
|
||||
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
|
||||
| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). |
|
||||
| `reason` | `create`, `resume`, `refresh`, `import` |
|
||||
| `purpose` | `interactive`, `history` |
|
||||
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
|
||||
@@ -1043,7 +1073,7 @@ Creation request
|
||||
→ agent.create hooks (plugin-ID order; registration order within each plugin)
|
||||
→ resolve defaults and validate provider configuration
|
||||
→ derive launch configuration with Paseo runtime tools and daemon prompt
|
||||
→ agent.session_open hooks (same ordering; env and plugin-local tools)
|
||||
→ agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
|
||||
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
|
||||
→ open provider session and save agent configuration
|
||||
```
|
||||
@@ -1055,8 +1085,9 @@ Creation request
|
||||
| `undefined` | Unchanged request |
|
||||
| Throws or returns invalid data | Operation fails; later callbacks do not run |
|
||||
|
||||
No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is
|
||||
saved; environment overrides are not persisted with it.
|
||||
There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers`
|
||||
accumulate only across callbacks within the same plugin. Returning a replacement map or array
|
||||
replaces that scope's prior value.
|
||||
|
||||
### Context and cleanup
|
||||
|
||||
|
||||
@@ -529,9 +529,12 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。
|
||||
|
||||
### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server}
|
||||
`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。
|
||||
`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。
|
||||
|
||||
在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。
|
||||
### 修改已保存的配置 {#change-saved-configuration}
|
||||
|
||||
在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项:
|
||||
|
||||
```ts
|
||||
server.before("agent.create", ({ request }) => {
|
||||
@@ -548,13 +551,6 @@ server.before("agent.create", ({ request }) => {
|
||||
sandbox_mode: "workspace-write",
|
||||
approval_policy: "on-request",
|
||||
},
|
||||
mcpServers: {
|
||||
...request.config.mcpServers,
|
||||
company: {
|
||||
type: "http",
|
||||
url: "https://tools.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -564,10 +560,37 @@ server.before("agent.create", ({ request }) => {
|
||||
| ------------------------------------------- | ------------------- |
|
||||
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
|
||||
| `providerOptions.web_search: "disabled"` | 由展开运算保留 |
|
||||
| 已有的 `mcpServers.search` | 由展开运算保留 |
|
||||
| 已有的 `mcpServers.company` | 被替换为上面的条目 |
|
||||
|
||||
所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。
|
||||
所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。
|
||||
|
||||
### 注入 MCP 服务器 {#inject-mcp-servers}
|
||||
|
||||
需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。
|
||||
插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点:
|
||||
|
||||
```ts
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "codex") return;
|
||||
|
||||
return {
|
||||
...request,
|
||||
mcpServers: {
|
||||
...request.mcpServers,
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。
|
||||
每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。
|
||||
替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。
|
||||
Paseo 按插件 ID 顺序合并各插件的最终映射。
|
||||
|
||||
名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。
|
||||
不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。
|
||||
`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent
|
||||
通过[会话重新打开行为](#attach-tools-to-agents)获取变化。
|
||||
|
||||
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
|
||||
|
||||
@@ -583,7 +606,7 @@ server.before("agent.session_open", ({ request }) => {
|
||||
});
|
||||
```
|
||||
|
||||
它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。
|
||||
会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。
|
||||
|
||||
### 为 Agent 附加工具 {#attach-tools-to-agents}
|
||||
|
||||
@@ -852,11 +875,11 @@ type PluginTurnOutcome =
|
||||
|
||||
### before 钩子 {#before-hooks}
|
||||
|
||||
| 名称 | 请求字段 | 可编辑 |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
|
||||
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools` | `env` 和 `tools` |
|
||||
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
|
||||
| 名称 | 请求字段 | 可编辑 |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
|
||||
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` |
|
||||
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
|
||||
|
||||
**`agent.create.config`** 使用 `AgentSessionConfig`:
|
||||
|
||||
@@ -880,6 +903,7 @@ type PluginTurnOutcome =
|
||||
"title": "Workspace notes",
|
||||
"labels": { "notes/enabled": "true" },
|
||||
"tools": [],
|
||||
"mcpServers": {},
|
||||
"reason": "resume",
|
||||
"purpose": "interactive",
|
||||
"env": { "COMPANY_ENV": "development" }
|
||||
@@ -892,6 +916,7 @@ type PluginTurnOutcome =
|
||||
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
|
||||
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
|
||||
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
|
||||
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
|
||||
| `reason` | `create`、`resume`、`refresh`、`import` |
|
||||
| `purpose` | `interactive`、`history` |
|
||||
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
|
||||
@@ -903,7 +928,7 @@ Creation request
|
||||
→ agent.create hooks (plugin-ID order; registration order within each plugin)
|
||||
→ resolve defaults and validate provider configuration
|
||||
→ derive launch configuration with Paseo runtime tools and daemon prompt
|
||||
→ agent.session_open hooks (same ordering; env and plugin-local tools)
|
||||
→ agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
|
||||
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
|
||||
→ open provider session and save agent configuration
|
||||
```
|
||||
@@ -915,7 +940,8 @@ Creation request
|
||||
| `undefined` | 未改变的请求 |
|
||||
| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 |
|
||||
|
||||
没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。
|
||||
没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。
|
||||
返回替换的映射或数组会替换该范围内之前的值。
|
||||
|
||||
### context 与清理 {#context-and-cleanup}
|
||||
|
||||
@@ -1187,7 +1213,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) {
|
||||
```tsx
|
||||
import { Markdown } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
<Markdown>{"## 审查\n\n检查**通过**。"}</Markdown>;
|
||||
<Markdown>{"## Review\n\nThe checks **passed**."}</Markdown>;
|
||||
```
|
||||
|
||||
`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
|
||||
|
||||
@@ -27,28 +27,29 @@ public docs; this skill indexes the references and examples.
|
||||
|
||||
Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws.
|
||||
|
||||
| Contribution | Registration | Use it when | Reference |
|
||||
| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
|
||||
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
|
||||
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
|
||||
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
|
||||
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
|
||||
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
|
||||
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
|
||||
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
|
||||
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
|
||||
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
|
||||
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
|
||||
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
|
||||
| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
|
||||
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
|
||||
| Contribution | Registration | Use it when | Reference |
|
||||
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
|
||||
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
|
||||
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
|
||||
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
|
||||
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
|
||||
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
|
||||
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
|
||||
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
|
||||
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
|
||||
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
|
||||
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
|
||||
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
|
||||
| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
|
||||
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
|
||||
|
||||
| Lifecycle task | Example |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
|
||||
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
|
||||
| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
|
||||
| Lifecycle task | Example |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
|
||||
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
|
||||
| Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
|
||||
| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) |
|
||||
|
||||
## Create the project
|
||||
|
||||
@@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam
|
||||
|
||||
Use TanStack Query for async request state, caching, and mutations.
|
||||
|
||||
### Contribute tools and MCP servers at session open
|
||||
|
||||
Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this
|
||||
callback inside `contribute(server)`:
|
||||
|
||||
```ts
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "codex") return;
|
||||
return {
|
||||
...request,
|
||||
mcpServers: {
|
||||
...request.mcpServers,
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks.
|
||||
They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent.
|
||||
See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents)
|
||||
for `defineAgentTool` and targeting, and
|
||||
[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions.
|
||||
|
||||
Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale.
|
||||
Each reopens before its next idle prompt without interrupting a running turn.
|
||||
`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn;
|
||||
`refresh()` only reads the current snapshot.
|
||||
|
||||
### Debug daemon-side behavior
|
||||
|
||||
Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;
|
||||
|
||||
Reference in New Issue
Block a user