From 5c232dbf1aa53c91d95d58f5f77339531741bd15 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Thu, 25 Dec 2025 22:28:05 -0500 Subject: [PATCH 1/3] feat(cli): add allowHeadless option for MCP servers in headless mode Adds `allowHeadless: true` config option for MCP servers, enabling specific MCP tools to work in headless mode without requiring --auto flag. Changes: - Add allowHeadless to MCP config schema - Pass allowHeadless from connection config to Tool objects - Check allowHeadless in tool enumeration (getRequestTools) - Check allowHeadless in execution permission (checkToolPermissionApproval) - Add 8 tests covering enumeration and execution permission Usage: ```yaml mcpServers: - name: Brave Search command: npx args: ["-y", "@modelcontextprotocol/server-brave-search"] allowHeadless: true # Enable in headless mode ``` Authored by: Aaron Lippold --- extensions/cli/src/stream/handleToolCalls.ts | 10 +- .../cli/src/stream/mcp-headless.test.ts | 311 ++++++++++++++++++ .../src/stream/streamChatResponse.helpers.ts | 6 +- extensions/cli/src/tools/index.tsx | 15 +- extensions/cli/src/tools/searchCode.ts | 2 +- extensions/cli/src/tools/types.ts | 1 + packages/config-yaml/src/schemas/mcp/index.ts | 1 + 7 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 extensions/cli/src/stream/mcp-headless.test.ts diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index 5150a436bd5..08d5628b2aa 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -198,9 +198,17 @@ export async function getRequestTools(isHeadless: boolean) { permissionsState.permissions, ); + // Allow tool if: + // 1. Explicitly allowed in permissions + // 2. Permission is "ask" and we're in interactive mode (can prompt user) + // 3. MCP tool with allowHeadless=true in headless mode + const allowMcpInHeadless = + !tool.isBuiltIn && isHeadless && tool.allowHeadless; + if ( result.permission === "allow" || - (result.permission === "ask" && !isHeadless) + (result.permission === "ask" && !isHeadless) || + allowMcpInHeadless ) { allowedTools.push(tool); } diff --git a/extensions/cli/src/stream/mcp-headless.test.ts b/extensions/cli/src/stream/mcp-headless.test.ts new file mode 100644 index 00000000000..dcbdbcd7731 --- /dev/null +++ b/extensions/cli/src/stream/mcp-headless.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, test, beforeEach } from "vitest"; + +import { + initializeServices, + serviceContainer, + SERVICE_NAMES, +} from "../services/index.js"; +import type { MCPServiceState } from "../services/types.js"; +import type { PreprocessedToolCall, Tool } from "../tools/types.js"; + +import { getRequestTools } from "./handleToolCalls.js"; +import { checkToolPermissionApproval } from "./streamChatResponse.helpers.js"; + +describe("MCP tools in headless mode", () => { + beforeEach(() => { + // Clean up service container state before each test + Object.values(SERVICE_NAMES).forEach((service) => { + (serviceContainer as any).services.delete(service); + (serviceContainer as any).factories.delete(service); + (serviceContainer as any).dependencies.delete(service); + }); + }); + + test("should exclude MCP tools by default in headless mode", async () => { + await initializeServices({ headless: true }); + + // Mock MCP state with a server that doesn't have allowHeadless + const mockMcpState: MCPServiceState = { + mcpService: null, + connections: [ + { + config: { + name: "test-server", + command: "npx", + args: ["test"], + // allowHeadless: undefined (default) + }, + status: "connected", + tools: [ + { + name: "mcp__test__search", + description: "Search tool", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], + prompts: [], + warnings: [], + }, + ], + tools: [], + prompts: [], + }; + + // Inject mock state + const mcpService = await serviceContainer.get(SERVICE_NAMES.MCP); + (mcpService as any).connections = mockMcpState.connections; + + const tools = await getRequestTools(true); // headless = true + const toolNames = tools.map((t) => t.function.name); + + // MCP tool should NOT be in the list (default behavior) + expect(toolNames).not.toContain("mcp__test__search"); + + // Built-in tools should still be available + expect(toolNames).toContain("Read"); + expect(toolNames).toContain("List"); + }); + + test("should include MCP tools when allowHeadless=true in headless mode", async () => { + await initializeServices({ headless: true }); + + // Mock MCP state with a server that HAS allowHeadless: true + const mockMcpState: MCPServiceState = { + mcpService: null, + connections: [ + { + config: { + name: "test-server", + command: "npx", + args: ["test"], + allowHeadless: true, // ← Explicitly allow in headless + }, + status: "connected", + tools: [ + { + name: "mcp__test__search", + description: "Search tool", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], + prompts: [], + warnings: [], + }, + ], + tools: [], + prompts: [], + }; + + // Inject mock state + const mcpService = await serviceContainer.get(SERVICE_NAMES.MCP); + (mcpService as any).connections = mockMcpState.connections; + + const tools = await getRequestTools(true); // headless = true + const toolNames = tools.map((t) => t.function.name); + + // MCP tool SHOULD be in the list (allowHeadless: true) + expect(toolNames).toContain("mcp__test__search"); + + // Built-in tools should still be available + expect(toolNames).toContain("Read"); + expect(toolNames).toContain("List"); + }); + + test("should include all MCP tools in interactive mode regardless of allowHeadless", async () => { + await initializeServices({ headless: false }); + + // Mock MCP state with allowHeadless: false + const mockMcpState: MCPServiceState = { + mcpService: null, + connections: [ + { + config: { + name: "test-server", + command: "npx", + args: ["test"], + allowHeadless: false, // Explicitly disallow headless + }, + status: "connected", + tools: [ + { + name: "mcp__test__search", + description: "Search tool", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ], + prompts: [], + warnings: [], + }, + ], + tools: [], + prompts: [], + }; + + // Inject mock state + const mcpService = await serviceContainer.get(SERVICE_NAMES.MCP); + (mcpService as any).connections = mockMcpState.connections; + + const tools = await getRequestTools(false); // headless = false (interactive) + const toolNames = tools.map((t) => t.function.name); + + // MCP tool SHOULD be available in interactive mode even with allowHeadless: false + expect(toolNames).toContain("mcp__test__search"); + }); + + test("should handle multiple MCP servers with different allowHeadless settings", async () => { + await initializeServices({ headless: true }); + + const mockMcpState: MCPServiceState = { + mcpService: null, + connections: [ + { + config: { + name: "safe-server", + command: "npx", + args: ["safe"], + allowHeadless: true, // Allowed in headless + }, + status: "connected", + tools: [ + { + name: "mcp__safe__read", + description: "Safe read tool", + inputSchema: { type: "object", properties: {} }, + }, + ], + prompts: [], + warnings: [], + }, + { + config: { + name: "restricted-server", + command: "npx", + args: ["restricted"], + allowHeadless: false, // Not allowed in headless + }, + status: "connected", + tools: [ + { + name: "mcp__restricted__write", + description: "Restricted write tool", + inputSchema: { type: "object", properties: {} }, + }, + ], + prompts: [], + warnings: [], + }, + ], + tools: [], + prompts: [], + }; + + const mcpService = await serviceContainer.get(SERVICE_NAMES.MCP); + (mcpService as any).connections = mockMcpState.connections; + + const tools = await getRequestTools(true); // headless = true + const toolNames = tools.map((t) => t.function.name); + + // Safe server tool should be available + expect(toolNames).toContain("mcp__safe__read"); + + // Restricted server tool should NOT be available + expect(toolNames).not.toContain("mcp__restricted__write"); + }); +}); + +describe("MCP tool execution permission in headless mode", () => { + // Helper to create a mock PreprocessedToolCall + function createMockToolCall( + toolName: string, + allowHeadless?: boolean, + ): PreprocessedToolCall { + const tool: Tool = { + name: toolName, + displayName: toolName, + description: "Test tool", + parameters: { type: "object", properties: {} }, + run: async () => "result", + isBuiltIn: false, + allowHeadless: allowHeadless ?? false, + }; + return { + id: "test-id", + name: toolName, + arguments: {}, + argumentsStr: "{}", + startNotified: false, + tool, + }; + } + + test("should approve MCP tool with allowHeadless=true in headless mode", async () => { + const toolCall = createMockToolCall("mcp__test__search", true); + // Empty policies array - no explicit allow/deny, so default is "ask" + const permissions = { policies: [] }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + expect(result.approved).toBe(true); + }); + + test("should deny MCP tool without allowHeadless in headless mode", async () => { + const toolCall = createMockToolCall("mcp__test__search", false); + const permissions = { policies: [] }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + expect(result.approved).toBe(false); + expect(result.denialReason).toBe("policy"); + }); + + test("should deny MCP tool with allowHeadless=undefined in headless mode", async () => { + const toolCall = createMockToolCall("mcp__test__search", undefined); + const permissions = { policies: [] }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + expect(result.approved).toBe(false); + expect(result.denialReason).toBe("policy"); + }); + + test("should approve explicitly allowed tools regardless of allowHeadless", async () => { + const toolCall = createMockToolCall("mcp__test__search", false); + // Explicit allow policy for this tool + const permissions = { + policies: [{ tool: "mcp__test__search", permission: "allow" as const }], + }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + expect(result.approved).toBe(true); + }); +}); diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index a60b10af449..eba98dd6a37 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -123,7 +123,11 @@ export async function checkToolPermissionApproval( return { approved: true }; } else if (permissionCheck.permission === "ask") { if (isHeadless) { - // "ask" tools are excluded in headless so can only get here by policy evaluation + // In headless mode, allow MCP tools with allowHeadless: true + if (toolCall.tool.allowHeadless) { + return { approved: true }; + } + // Otherwise, "ask" tools are excluded in headless return { approved: false, denialReason: "policy" }; } const userApproved = await requestUserPermission(toolCall, callbacks); diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index efc7a1e5f79..8f0f486bb01 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -122,7 +122,14 @@ export async function getAllAvailableTools( const mcpState = await serviceContainer.get( SERVICE_NAMES.MCP, ); - tools.push(...mcpState.tools.map(convertMcpToolToContinueTool)); + + // Add MCP tools with allowHeadless flag from server config + for (const connection of mcpState.connections) { + const mcpTools = connection.tools.map((tool) => + convertMcpToolToContinueTool(tool, connection.config.allowHeadless), + ); + tools.push(...mcpTools); + } return tools; } @@ -174,7 +181,10 @@ export function convertToolToChatCompletionTool( }; } -export function convertMcpToolToContinueTool(mcpTool: MCPTool): Tool { +export function convertMcpToolToContinueTool( + mcpTool: MCPTool, + allowHeadless?: boolean, +): Tool { return { name: mcpTool.name, displayName: mcpTool.name.replace("mcp__", "").replace("ide__", ""), @@ -189,6 +199,7 @@ export function convertMcpToolToContinueTool(mcpTool: MCPTool): Tool { }, readonly: undefined, // MCP tools don't have readonly property isBuiltIn: false, + allowHeadless: allowHeadless ?? false, // Default to false for security run: async (args: any) => { const result = await services.mcp?.runTool(mcpTool.name, args); return JSON.stringify(result?.content) ?? ""; diff --git a/extensions/cli/src/tools/searchCode.ts b/extensions/cli/src/tools/searchCode.ts index e7fa4e5025f..99f3fdc740c 100644 --- a/extensions/cli/src/tools/searchCode.ts +++ b/extensions/cli/src/tools/searchCode.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as util from "util"; import { ContinueError, ContinueErrorReason } from "core/util/errors.js"; -import { findUp } from "find-up"; +import findUp from "find-up"; import { Tool } from "./types.js"; diff --git a/extensions/cli/src/tools/types.ts b/extensions/cli/src/tools/types.ts index 5adb45a92f8..2faa4ab1829 100644 --- a/extensions/cli/src/tools/types.ts +++ b/extensions/cli/src/tools/types.ts @@ -40,6 +40,7 @@ export interface Tool { run: (args: any) => Promise; readonly?: boolean; // Indicates if the tool is readonly isBuiltIn: boolean; + allowHeadless?: boolean; // Allow this MCP tool in headless mode (default: false) evaluateToolCallPolicy?: ( basePolicy: ToolPolicy, parsedArgs: Record, diff --git a/packages/config-yaml/src/schemas/mcp/index.ts b/packages/config-yaml/src/schemas/mcp/index.ts index 5d7430355f8..885677f9f91 100644 --- a/packages/config-yaml/src/schemas/mcp/index.ts +++ b/packages/config-yaml/src/schemas/mcp/index.ts @@ -8,6 +8,7 @@ const baseMcpServerSchema = z.object({ sourceFile: z.string().optional(), // Added during loading sourceSlug: z.string().optional(), // Added during loading connectionTimeout: z.number().gt(0).optional(), + allowHeadless: z.boolean().optional(), // Allow MCP tools in headless mode (default: false) }); const stdioMcpServerSchema = baseMcpServerSchema.extend({ From 8aff77c143635ba777127df84ee3ac45b50ecdc2 Mon Sep 17 00:00:00 2001 From: "continue[bot]" Date: Fri, 26 Dec 2025 15:27:53 +0000 Subject: [PATCH 2/3] docs: add allowHeadless documentation for MCP servers in CLI headless mode - Add allowHeadless property to mcpServers reference documentation - Add comprehensive section in MCP deep dive explaining headless mode behavior - Update CLI overview to mention MCP tools headless mode exclusion - Include examples and security considerations for allowHeadless usage Generated with [Continue](https://continue.dev) Co-Authored-By: Continue Co-authored-by: nate --- docs/cli/overview.mdx | 2 ++ docs/customize/deep-dives/mcp.mdx | 46 +++++++++++++++++++++++++++++++ docs/reference.mdx | 7 +++++ 3 files changed, 55 insertions(+) diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index f2063b60587..5784f9d75da 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -87,6 +87,8 @@ cn -p "Update documentation based on recent code changes" **In headless mode**, tools with "ask" permission are automatically excluded to prevent the AI from seeing tools it cannot call. This ensures reliable automation without user intervention. + **MCP tools** are excluded in headless mode by default. To enable specific trusted MCP servers in headless workflows, set `allowHeadless: true` in your [MCP server configuration](/customize/deep-dives/mcp#how-to-use-mcp-servers-in-cli-headless-mode). + **In TUI mode**, tools with "ask" permission are available and will prompt for confirmation when the AI attempts to use them. 💡 **Tip**: If your workflow requires tools that need confirmation (like file writes or terminal commands), use TUI mode. For fully automated workflows with read-only operations, use headless mode. diff --git a/docs/customize/deep-dives/mcp.mdx b/docs/customize/deep-dives/mcp.mdx index 2ad80571159..fce4e54f922 100644 --- a/docs/customize/deep-dives/mcp.mdx +++ b/docs/customize/deep-dives/mcp.mdx @@ -151,6 +151,52 @@ These remote transport options allow you to connect to MCP servers hosted on rem For detailed information about transport mechanisms and their use cases, refer to the official MCP documentation on [transports](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse). +### How to Use MCP Servers in CLI Headless Mode + +By default, MCP tools are not available in [CLI headless mode](/cli/overview#headless-mode-production-automation) for security reasons. This prevents untrusted tools from executing without user approval during automated workflows. + + + **What is headless mode?** + + Headless mode is used for automated, non-interactive workflows like CI/CD pipelines, git hooks, and scripting. In this mode, the CLI cannot prompt for user confirmation. + + +To enable specific trusted MCP servers in headless mode, add `allowHeadless: true` to your server configuration: + +```yaml +mcpServers: + - name: Brave Search + command: npx + args: + - "-y" + - "@modelcontextprotocol/server-brave-search" + allowHeadless: true # Enable in headless mode + env: + BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} +``` + + + **Security consideration:** Only set `allowHeadless: true` for MCP servers you fully trust, as their tools will be able to execute without user confirmation in automated workflows. + + +**When to use `allowHeadless`:** + + + + - Read-only search APIs + - Documentation retrieval services + - Trusted internal tools + - Well-scoped automation utilities + + + + - File system modification tools + - Database write operations + - External API calls that modify state + - Tools with broad permissions + + + ### How to Work with Secrets in MCP Servers With some MCP servers you will need to use API keys or other secrets. You can leverage locally stored environments secrets diff --git a/docs/reference.mdx b/docs/reference.mdx index 23a08cb4811..76fe5d707f5 100644 --- a/docs/reference.mdx +++ b/docs/reference.mdx @@ -316,6 +316,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) is a - `cwd`: An optional working directory to run the command in. Can be absolute or relative path. - `requestOptions`: Optional request options for `sse` and `streamable-http` servers. Same format as [model requestOptions](#models). - `connectionTimeout`: Optional timeout for _initial_ connection to MCP server +- `allowHeadless`: Enable this MCP server's tools in [CLI headless mode](/cli/overview#headless-mode-production-automation). Defaults to `false`. When `true`, tools from this server can be used in automated workflows without user confirmation. **Example:** @@ -333,6 +334,12 @@ mcpServers: cwd: /Users/NAME/project env: NODE_ENV: production + - name: Brave Search + command: npx + args: + - "-y" + - "@modelcontextprotocol/server-brave-search" + allowHeadless: true # Enable in CLI headless mode for automation ``` ### `data` From 2cfb2045c5a007407a7e513c08af51a8f1ef9bc4 Mon Sep 17 00:00:00 2001 From: "continue[bot]" Date: Fri, 26 Dec 2025 15:32:17 +0000 Subject: [PATCH 3/3] fix: respect explicit exclusions in allowHeadless and preserve undefined in tests Issue 1 (P1): allowMcpInHeadless now only upgrades 'ask' permissions, not 'exclude' - Modified logic to check result.permission === 'ask' before applying allowHeadless - This prevents allowHeadless from bypassing explicit tool exclusions - Added test case to verify excluded tools remain excluded Issue 2 (P2): Test helper now preserves undefined allowHeadless values - Changed from 'allowHeadless ?? false' to conditional property spread - This allows tests to properly verify undefined behavior vs explicit false Generated with [Continue](https://continue.dev) Co-Authored-By: Continue Co-authored-by: nate --- extensions/cli/src/stream/handleToolCalls.ts | 8 +++++-- .../cli/src/stream/mcp-headless.test.ts | 22 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index 08d5628b2aa..cdc17ec6783 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -201,9 +201,13 @@ export async function getRequestTools(isHeadless: boolean) { // Allow tool if: // 1. Explicitly allowed in permissions // 2. Permission is "ask" and we're in interactive mode (can prompt user) - // 3. MCP tool with allowHeadless=true in headless mode + // 3. MCP tool with allowHeadless=true can upgrade "ask" permission in headless mode + // (but should not override explicit "exclude" permissions) const allowMcpInHeadless = - !tool.isBuiltIn && isHeadless && tool.allowHeadless; + result.permission === "ask" && + !tool.isBuiltIn && + isHeadless && + tool.allowHeadless; if ( result.permission === "allow" || diff --git a/extensions/cli/src/stream/mcp-headless.test.ts b/extensions/cli/src/stream/mcp-headless.test.ts index dcbdbcd7731..adf56cf8296 100644 --- a/extensions/cli/src/stream/mcp-headless.test.ts +++ b/extensions/cli/src/stream/mcp-headless.test.ts @@ -235,7 +235,8 @@ describe("MCP tool execution permission in headless mode", () => { parameters: { type: "object", properties: {} }, run: async () => "result", isBuiltIn: false, - allowHeadless: allowHeadless ?? false, + // Preserve undefined to test actual undefined behavior + ...(allowHeadless !== undefined ? { allowHeadless } : {}), }; return { id: "test-id", @@ -308,4 +309,23 @@ describe("MCP tool execution permission in headless mode", () => { expect(result.approved).toBe(true); }); + + test("should deny explicitly excluded tools even with allowHeadless=true", async () => { + const toolCall = createMockToolCall("mcp__test__search", true); + // Explicit exclude policy for this tool + const permissions = { + policies: [{ tool: "mcp__test__search", permission: "exclude" as const }], + }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + // allowHeadless should NOT bypass explicit exclusions + expect(result.approved).toBe(false); + expect(result.denialReason).toBe("policy"); + }); });