diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index 5150a436bd5..77c5a690fab 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -198,9 +198,20 @@ 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 (but respect explicit exclusions) + const allowMcpInHeadless = + !tool.isBuiltIn && + isHeadless && + tool.allowHeadless && + result.permission !== "exclude"; + 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..58853288b2d --- /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 properly test undefined vs explicit false + ...(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 - should override allowHeadless + const permissions = { + policies: [{ tool: "mcp__test__search", permission: "exclude" as const }], + }; + + const result = await checkToolPermissionApproval( + permissions, + toolCall, + undefined, // no callbacks + true, // isHeadless + ); + + // Explicit exclusion should be respected even with allowHeadless=true + 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/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/extensions/cli/vitest.config.ts b/extensions/cli/vitest.config.ts index 81b6c4b7fd7..271762995cb 100644 --- a/extensions/cli/vitest.config.ts +++ b/extensions/cli/vitest.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ environment: "node", setupFiles: ["./vitest.setup.ts"], exclude: ["**/node_modules/**", "**/dist/**", "**/*.e2e.*", "**/e2e/**"], + // Enable CommonJS interop for packages like find-up v5 that use module.exports + deps: { + interopDefault: true, + }, coverage: { reporter: ["text", "json", "html"], exclude: [ diff --git a/extensions/cli/vitest.setup.ts b/extensions/cli/vitest.setup.ts index ac91939574b..cab1ab2ffee 100644 --- a/extensions/cli/vitest.setup.ts +++ b/extensions/cli/vitest.setup.ts @@ -1,3 +1,6 @@ +import * as fs from "fs"; +import * as path from "path"; + import { vi } from "vitest"; import { resetConsoleOverrides } from "./src/init.js"; @@ -6,6 +9,42 @@ import { resetConsoleOverrides } from "./src/init.js"; process.env.CONTINUE_CLI_ENABLE_TELEMETRY = "0"; process.env.CONTINUE_ALLOW_ANONYMOUS_TELEMETRY = "0"; +// Mock core/util/paths to read CONTINUE_GLOBAL_DIR dynamically +// This fixes test isolation issues where the module caches the env var at load time +vi.mock("core/util/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + + // Create a dynamic version of getContinueGlobalPath that reads env var each time + const getContinueGlobalPath = () => { + const continuePath = + process.env.CONTINUE_GLOBAL_DIR || + path.join(process.env.HOME || "", ".continue"); + if (!fs.existsSync(continuePath)) { + fs.mkdirSync(continuePath, { recursive: true }); + } + return continuePath; + }; + + const getIndexFolderPath = () => { + const indexPath = path.join(getContinueGlobalPath(), "index"); + if (!fs.existsSync(indexPath)) { + fs.mkdirSync(indexPath, { recursive: true }); + } + return indexPath; + }; + + const getGlobalContextFilePath = () => { + return path.join(getIndexFolderPath(), "globalContext.json"); + }; + + return { + ...actual, + getContinueGlobalPath, + getIndexFolderPath, + getGlobalContextFilePath, + }; +}); + // Mock fetch to prevent actual API calls in tests const originalFetch = global.fetch; global.fetch = vi 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({