Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e2a2d1588d fix(agents): validate optional MCP requirements before plugin injection
Keep optional approval decisions in the provider registry and preserve required plugin servers. Remove requirements alongside stripped internal servers so persistence cannot retain dangling metadata.
2026-10-29 09:14:58 +01:00
Mohamed Boudra 2325a2f32d test(agents): cover optional MCP through registered provider contracts 2026-10-29 09:04:20 +01:00
Mohamed Boudra 12a33ea21a fix(agents): restore optional MCP requirements on daemon hydration 2026-10-29 08:59:45 +01:00
Mohamed Boudra e1684aece0 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.
2026-10-29 08:58:25 +01:00
Mohamed BoudraandRyan Winograd c47e1f178b fix(providers): allow optional exact MCP preapproval
Separate automatic approval convenience from strict tool-policy requirements so Hub can launch any available provider. Unsupported providers retain ordinary MCP injection and native permissions; strict requests still fail closed.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
2026-10-29 08:51:20 +01:00
17 changed files with 249 additions and 53 deletions
+9
View File
@@ -44,6 +44,15 @@ fall back to creating a fresh agent when continuation was requested.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
Hub sends `toolPolicy.enforcement: "if-supported"` and
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
Without it, Hub preserves its existing strict tool-policy request. Available
providers remain selectable; providers lacking strict approval support need an
updated daemon to launch from Hub. Required external/plugin servers and explicitly
required tool policies keep their existing refusal semantics.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
+10 -3
View File
@@ -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
+4
View File
@@ -509,10 +509,14 @@ export interface McpToolRef {
}
export interface ToolPolicy {
/** Missing or required fails closed; if-supported requests approval convenience only. */
enforcement?: "required" | "if-supported";
preapproved: McpToolRef[];
}
export interface AgentSessionConfig {
/** Per-server launch requirement; unspecified servers are required. */
mcpServerRequirements?: Record<string, "optional" | "required">;
provider: AgentProvider;
cwd: string;
/**
+35
View File
@@ -571,3 +571,38 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
const toolPolicy = {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
enforcement: "if-supported",
};
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req",
config: { provider: "pi", cwd: "/tmp", toolPolicy },
});
expect(parsed).toMatchObject({ config: { toolPolicy } });
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy: { preapproved: toolPolicy.preapproved },
}).success,
).toBe(true);
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy,
}).success,
).toBe(false);
});
+5 -1
View File
@@ -490,8 +490,11 @@ const AgentSessionConfigSchema = z.object({
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(),
toolPolicy: ToolPolicySchema.extend({
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(),
});
@@ -3671,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(),
@@ -19,6 +19,7 @@ import {
type AgentManagerEvent,
type ManagedAgent,
} from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -445,7 +446,7 @@ class EnvProbeAgentClient extends TestAgentClient {
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly provider: AgentProvider;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
@@ -453,7 +454,9 @@ class TestAgentSession implements AgentSession {
private turnIdCounter = 0;
private interrupted = false;
constructor(private readonly config: AgentSessionConfig) {}
constructor(private readonly config: AgentSessionConfig) {
this.provider = config.provider;
}
async run(): Promise<AgentRunResult> {
return {
@@ -3822,54 +3825,85 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
expect(snapshot.config.mcpServers).toBeUndefined();
});
test("createAgent preserves a user-provided paseo MCP config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
test.each([
{ provider: "pi", supportsMcp: true },
{ provider: "pi", supportsMcp: false },
{ provider: "codex", supportsMcp: false },
])(
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
async ({ provider, 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,
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
const client = new CaptureClient(provider);
const manager = new AgentManager({
clients: {
[provider]: client,
},
providerDefinitions: {
[provider]: {
enabled: true,
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
},
},
},
undefined,
{ workspaceId: undefined },
);
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
const snapshot = await manager.createAgent(
{
provider,
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 },
);
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",
});
const expectedPolicy =
provider === "codex"
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
: undefined;
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
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-"));
@@ -12575,3 +12609,21 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
test("MCP requirements cannot mark a later plugin server optional", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
await expect(
manager.createAgent(
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
undefined,
{ workspaceId: undefined },
),
).rejects.toThrow(
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
);
});
@@ -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;
@@ -5457,6 +5465,7 @@ export class AgentManager {
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider);
this.validateToolPolicyServers(config);
this.validateMcpServerRequirements(config);
if (config.toolPolicy && !definition?.applyToolPolicy) {
throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
@@ -5467,6 +5476,16 @@ export class AgentManager {
: config;
}
private validateMcpServerRequirements(config: AgentSessionConfig): void {
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
throw new Error(
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
);
}
}
}
private validateToolPolicyServers(config: AgentSessionConfig): void {
if (!config.toolPolicy) return;
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
@@ -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>;
@@ -135,6 +135,29 @@ describe("provider-owned option schemas", () => {
});
describe("exact MCP preapproval mappings", () => {
test("optional exact preapproval permits unsupported providers without changing their MCP config", () => {
const registry = buildProviderRegistry(createTestLogger());
const config = {
provider: "pi",
cwd: "/tmp",
mcpServers: { hub: { type: "http" as const, url: "http://localhost/hub" } },
};
expect(
registry.pi.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" }),
).toEqual({ ...config, toolPolicy: undefined });
});
test.each(["claude", "codex", "opencode"])(
"optional grants retain exact approval for %s",
(provider) => {
const registry = buildProviderRegistry(createTestLogger());
const config = { provider, cwd: "/tmp" };
expect(
registry[provider]!.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" })
.toolPolicy,
).toEqual(hubPolicy);
},
);
test("unsupported providers fail closed with an unattended-execution error", () => {
const registry = buildProviderRegistry(createTestLogger());
expect(() =>
@@ -652,12 +652,15 @@ function createRegistryEntry(
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
applyToolPolicy: (config, toolPolicy) => {
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
throw new ToolPolicyUnsupportedError(provider);
if (toolPolicy.enforcement !== "if-supported")
throw new ToolPolicyUnsupportedError(provider);
return { ...config, toolPolicy: undefined };
}
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
return {
...config,
toolPolicy: toolPolicy
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
toolPolicy: exactPolicy
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
: undefined,
};
},
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
const BASE_CONFIG: AgentSessionConfig = {
provider: "claude",
@@ -49,3 +49,15 @@ describe("withRuntimePaseoMcpServer", () => {
expect(result.mcpServers).toBeUndefined();
});
});
test("stripping an internal MCP server also strips its launch requirement", () => {
const result = stripInternalPaseoMcpServer({
...BASE_CONFIG,
mcpServerRequirements: { paseo: "optional", hub: "optional" },
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1/mcp/agents?callerAgentId=agent-1" },
hub: { type: "http", url: "https://hub.test/mcp" },
},
});
expect(result.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -18,6 +18,11 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
const next = { ...config };
if (next.mcpServerRequirements?.[PASEO_MCP_SERVER_NAME]) {
next.mcpServerRequirements = { ...next.mcpServerRequirements };
delete next.mcpServerRequirements[PASEO_MCP_SERVER_NAME];
if (Object.keys(next.mcpServerRequirements).length === 0) delete next.mcpServerRequirements;
}
if (Object.keys(nextMcpServers).length > 0) {
next.mcpServers = nextMcpServers;
} else {
@@ -214,3 +214,14 @@ describe("persistence hooks", () => {
expect(handle).toBeNull();
});
});
test("restores optional MCP requirements for continuation", () => {
const record = createRecord({
config: {
mcpServerRequirements: { hub: "optional" },
mcpServers: { hub: { type: "http", url: "https://hub.test/mcp" } },
},
});
expect(buildConfigOverrides(record).mcpServerRequirements).toEqual({ hub: "optional" });
expect(buildSessionConfig(record)?.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -78,6 +78,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
mcpServers: record.config?.mcpServers ?? undefined,
mcpServerRequirements: record.config?.mcpServerRequirements ?? undefined,
});
}
@@ -100,6 +101,7 @@ export function buildSessionConfig(
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
mcpServerRequirements: overrides.mcpServerRequirements,
});
}
@@ -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