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` diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index 5150a436bd5..cdc17ec6783 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -198,9 +198,21 @@ 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 can upgrade "ask" permission in headless mode + // (but should not override explicit "exclude" permissions) + const allowMcpInHeadless = + result.permission === "ask" && + !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..adf56cf8296 --- /dev/null +++ b/extensions/cli/src/stream/mcp-headless.test.ts @@ -0,0 +1,331 @@ +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, + // Preserve undefined to test actual undefined behavior + ...(allowHeadless !== undefined ? { allowHeadless } : {}), + }; + 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); + }); + + 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"); + }); +}); 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({