Skip to content

Commit 1a024f3

Browse files
authored
fix: allow configured MCP servers in non-interactive mode (google-gemini#27215)
1 parent 5650fa9 commit 1a024f3

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

packages/core/src/policy/config.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { Storage } from '../config/storage.js';
2525
import * as tomlLoader from './toml-loader.js';
2626
import { coreEvents } from '../utils/events.js';
27+
import { MCPServerConfig } from '../config/config.js';
2728

2829
vi.unmock('../config/storage.js');
2930

@@ -279,6 +280,145 @@ describe('createPolicyEngineConfig', () => {
279280
expect(untrustedRule).toBeUndefined();
280281
});
281282

283+
it('should NOT automatically allow configured MCP servers in non-interactive mode by default', async () => {
284+
const config = await createPolicyEngineConfig(
285+
{
286+
mcpServers: {
287+
'server-1': new MCPServerConfig('node', []),
288+
},
289+
},
290+
ApprovalMode.DEFAULT,
291+
MOCK_DEFAULT_DIR,
292+
false, // non-interactive
293+
);
294+
295+
const rule = config.rules?.find(
296+
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
297+
);
298+
expect(rule).toBeUndefined();
299+
});
300+
301+
it('should automatically allow configured MCP servers in non-interactive mode if opted-in', async () => {
302+
const config = await createPolicyEngineConfig(
303+
{
304+
mcp: { autoAllowInHeadless: true },
305+
mcpServers: {
306+
'server-1': new MCPServerConfig('node', []),
307+
'server-2': new MCPServerConfig('python', []),
308+
},
309+
},
310+
ApprovalMode.DEFAULT,
311+
MOCK_DEFAULT_DIR,
312+
false, // non-interactive
313+
);
314+
315+
const rule1 = config.rules?.find(
316+
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
317+
);
318+
const rule2 = config.rules?.find(
319+
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
320+
);
321+
322+
expect(rule1).toBeDefined();
323+
expect(rule1?.source).toBe('Settings (Headless MCP Auto-Allow)');
324+
expect(rule2).toBeDefined();
325+
expect(rule2?.source).toBe('Settings (Headless MCP Auto-Allow)');
326+
});
327+
328+
it('should NOT automatically allow configured MCP servers in interactive mode even if opted-in', async () => {
329+
const config = await createPolicyEngineConfig(
330+
{
331+
mcp: { autoAllowInHeadless: true },
332+
mcpServers: {
333+
'server-1': new MCPServerConfig('node', []),
334+
},
335+
},
336+
ApprovalMode.DEFAULT,
337+
MOCK_DEFAULT_DIR,
338+
true, // interactive
339+
);
340+
341+
const rule = config.rules?.find(
342+
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
343+
);
344+
expect(rule).toBeUndefined();
345+
});
346+
347+
it('should NOT duplicate allow rules if an MCP server is already explicitly allowed, wildcard allowed, or trusted', async () => {
348+
const config = await createPolicyEngineConfig(
349+
{
350+
mcp: {
351+
autoAllowInHeadless: true,
352+
allowed: ['server-1', '*'],
353+
},
354+
mcpServers: {
355+
'server-1': new MCPServerConfig('node', []),
356+
'server-2': new MCPServerConfig('node', []),
357+
'server-3': { trust: true },
358+
'server-4': new MCPServerConfig('node', []),
359+
},
360+
},
361+
ApprovalMode.DEFAULT,
362+
MOCK_DEFAULT_DIR,
363+
false, // non-interactive
364+
);
365+
366+
// server-1: already in mcp.allowed
367+
const rules1 = config.rules?.filter(
368+
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
369+
);
370+
expect(rules1).toHaveLength(1);
371+
expect(rules1?.[0].source).toBe('Settings (MCP Allowed)');
372+
373+
// server-2: covered by '*' in mcp.allowed
374+
// Note: the logic adds a rule for '*' which will match server-2 at runtime,
375+
// but the loop in headless auto-allow should skip adding a specific rule for server-2.
376+
const rules2 = config.rules?.filter(
377+
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
378+
);
379+
expect(rules2).toHaveLength(0);
380+
381+
// server-3: already trusted
382+
const rules3 = config.rules?.filter(
383+
(r) => r.mcpName === 'server-3' && r.decision === PolicyDecision.ALLOW,
384+
);
385+
expect(rules3).toHaveLength(1);
386+
expect(rules3?.[0].source).toBe('Settings (MCP Trusted)');
387+
388+
// server-4: NOT explicitly allowed or trusted, but SHOULD NOT be added because '*' exists in mcp.allowed
389+
const rules4 = config.rules?.filter(
390+
(r) => r.mcpName === 'server-4' && r.decision === PolicyDecision.ALLOW,
391+
);
392+
expect(rules4).toHaveLength(0);
393+
394+
// Verify the wildcard rule exists
395+
const wildcardRule = config.rules?.find(
396+
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
397+
);
398+
expect(wildcardRule).toBeDefined();
399+
expect(wildcardRule?.toolName).toBe('mcp_*');
400+
});
401+
402+
it('should use correct tool name pattern for wildcard server in headless auto-allow', async () => {
403+
const config = await createPolicyEngineConfig(
404+
{
405+
mcp: { autoAllowInHeadless: true },
406+
mcpServers: {
407+
'*': new MCPServerConfig('node', []),
408+
},
409+
},
410+
ApprovalMode.DEFAULT,
411+
MOCK_DEFAULT_DIR,
412+
false, // non-interactive
413+
);
414+
415+
const rule = config.rules?.find(
416+
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
417+
);
418+
expect(rule).toBeDefined();
419+
expect(rule?.toolName).toBe('mcp_*');
420+
});
421+
282422
it('should handle multiple MCP server configurations together', async () => {
283423
const config = await createPolicyEngineConfig(
284424
{

packages/core/src/policy/config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,38 @@ export async function createPolicyEngineConfig(
600600
}
601601
}
602602

603+
// In non-interactive mode, automatically allow all configured MCP servers if opted-in.
604+
// This ensures that tools provided by these servers are available without
605+
// requiring explicit entries in settings.mcp.allowed.
606+
if (
607+
!interactive &&
608+
settings.mcp?.autoAllowInHeadless &&
609+
settings.mcpServers
610+
) {
611+
for (const serverName of Object.keys(settings.mcpServers)) {
612+
// Avoid duplicates if already explicitly allowed, allowed via wildcard, or trusted.
613+
if (
614+
settings.mcp?.allowed?.includes(serverName) ||
615+
settings.mcp?.allowed?.includes('*') ||
616+
settings.mcpServers[serverName].trust
617+
) {
618+
continue;
619+
}
620+
621+
rules.push({
622+
toolName:
623+
serverName === '*'
624+
? `${MCP_TOOL_PREFIX}*`
625+
: `${MCP_TOOL_PREFIX}${serverName}_*`,
626+
mcpName: serverName,
627+
decision: PolicyDecision.ALLOW,
628+
priority: ALLOWED_MCP_SERVER_PRIORITY,
629+
source: 'Settings (Headless MCP Auto-Allow)',
630+
modes: nonPlanModes,
631+
});
632+
}
633+
}
634+
603635
return {
604636
rules,
605637
checkers,

packages/core/src/policy/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export interface PolicySettings {
333333
mcp?: {
334334
excluded?: string[];
335335
allowed?: string[];
336+
autoAllowInHeadless?: boolean;
336337
};
337338
tools?: {
338339
core?: string[];

0 commit comments

Comments
 (0)