mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +00:00
fix(agents): make injected MCP launch requirements explicit
Optional injected servers must survive persistence and reload without turning missing transport support into launch refusal. Required external and plugin servers keep their existing refusal. Advertise the optional MCP contract so older hosts retain established strict launch behavior.
This commit is contained in:
+10
-3
@@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
|
||||
(the default) fails closed unless the provider can approve exact injected server/tool
|
||||
identities without approving native tools. Native agent creation also accepts
|
||||
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
|
||||
unsupported providers retain ordinary MCP configuration and native permissions.
|
||||
Hub uses that optional request; MCP support and exact approval are not provider
|
||||
eligibility requirements. Optional injection is declared separately with
|
||||
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
|
||||
(including plugin-owned servers) keep their required semantics. The daemon
|
||||
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
|
||||
|
||||
## Core adapter patterns
|
||||
|
||||
|
||||
@@ -515,6 +515,8 @@ export interface ToolPolicy {
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
/** Per-server launch requirement; unspecified servers are required. */
|
||||
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
/**
|
||||
|
||||
@@ -494,6 +494,7 @@ const AgentSessionConfigSchema = z.object({
|
||||
enforcement: z.enum(["required", "if-supported"]).optional(),
|
||||
}).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -3673,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
optionalMcpSupport: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
|
||||
@@ -3822,60 +3822,71 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
|
||||
expect(snapshot.config.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createAgent preserves injected MCP when optional approval is unavailable", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
test.each([true, false])(
|
||||
"createAgent permits optional approval with MCP capability %s",
|
||||
async (supportsMcp) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return new McpCapableTestAgentSession(config);
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
toolPolicy: {
|
||||
enforcement: "if-supported",
|
||||
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
mcpServerRequirements: { paseo: "optional" },
|
||||
cwd: workdir,
|
||||
toolPolicy: {
|
||||
enforcement: "if-supported",
|
||||
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
|
||||
},
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
expect(snapshot.config.toolPolicy).toBeUndefined();
|
||||
expect(client.lastConfig?.toolPolicy).toBeUndefined();
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
});
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
|
||||
paseo: "optional",
|
||||
});
|
||||
await manager.reloadAgentSession(snapshot.id);
|
||||
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
|
||||
paseo: "optional",
|
||||
});
|
||||
expect(snapshot.config.toolPolicy).toBeUndefined();
|
||||
expect(client.lastConfig?.toolPolicy).toBeUndefined();
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
},
|
||||
);
|
||||
|
||||
test("createAgent fails when cwd does not exist", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
|
||||
@@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (record.config.providerOptions != null) {
|
||||
config.providerOptions = record.config.providerOptions;
|
||||
}
|
||||
if (record.config.mcpServerRequirements != null)
|
||||
config.mcpServerRequirements = record.config.mcpServerRequirements;
|
||||
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
|
||||
if (record.config.systemPrompt != null) {
|
||||
config.systemPrompt = record.config.systemPrompt;
|
||||
@@ -1686,7 +1688,7 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
if (
|
||||
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
||||
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
|
||||
existing.session.capabilities.supportsMcpServers !== true
|
||||
) {
|
||||
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
||||
@@ -3888,13 +3890,19 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
|
||||
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
|
||||
(name) => config.mcpServerRequirements?.[name] !== "optional",
|
||||
);
|
||||
}
|
||||
|
||||
private async requireExternalMcpSupport(
|
||||
session: AgentSession,
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
||||
!this.hasRequiredExternalMcpServers(launchConfig) ||
|
||||
session.capabilities.supportsMcpServers === true
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.systemPrompt) {
|
||||
serializable.systemPrompt = config.systemPrompt;
|
||||
}
|
||||
if (config.mcpServerRequirements) {
|
||||
serializable.mcpServerRequirements = config.mcpServerRequirements;
|
||||
}
|
||||
if (config.mcpServers) {
|
||||
serializable.mcpServers = config.mcpServers;
|
||||
}
|
||||
|
||||
@@ -607,6 +607,7 @@ export interface AgentSessionConfig {
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/**
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
|
||||
@@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.nullable()
|
||||
.optional(),
|
||||
systemPrompt: z.string().nullable().optional(),
|
||||
mcpServerRequirements: z
|
||||
.record(z.string(), z.enum(["optional", "required"]))
|
||||
.nullable()
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
@@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick<
|
||||
| "toolPolicy"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
| "mcpServerRequirements"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
|
||||
@@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceSortTimes: true,
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
optionalMcpSupport: true,
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: true,
|
||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
||||
|
||||
Reference in New Issue
Block a user