diff --git a/.gitignore b/.gitignore index 9b128bb..fb1a201 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ yarn-error.log* # Claude Code local settings CLAUDE.local.md .claude/ +ngrok-policy.yml diff --git a/.vscode/launch.json b/.vscode/launch.json index 1522ba4..6109147 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,19 @@ ], "preLaunchTask": "npm: build" }, + { + "name": "Run Extension (Open This Workspace)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/dist/**/*.js" + ], + "preLaunchTask": "npm: build" + }, { "name": "Run Extension (No Build)", "type": "extensionHost", diff --git a/README.md b/README.md index fd6cdc3..60d354e 100644 --- a/README.md +++ b/README.md @@ -30,25 +30,46 @@ A Visual Studio Code extension that exposes an MCP (Model Context Protocol) serv | Tool | Description | |------|-------------| -| 📂 `vscode_open_folder` | Open a workspace folder | -| 📋 `vscode_get_open_folders` | Get currently open workspace folder(s) | -| 🏷️ `vscode_document_symbols` | Get all symbols in a document | -| 🔍 `vscode_workspace_symbols` | Search symbols across the workspace | -| 🎯 `vscode_go_to_definition` | Find symbol definitions | -| 🔗 `vscode_find_references` | Find all references to a symbol | -| 💡 `vscode_hover_info` | Get type info and documentation | -| ⚠️ `vscode_diagnostics` | Get errors and warnings | -| 📞 `vscode_call_hierarchy` | Get incoming/outgoing calls | -| ✏️ `vscode_completions` | Get code completions at a position | -| 📝 `vscode_signature_help` | Get function signature help | -| 🏗️ `vscode_type_hierarchy` | Get type hierarchy information | -| 🔧 `vscode_code_actions` | Get available code actions/quick fixes | -| 🎨 `vscode_format_document` | Format an entire document | -| ✂️ `vscode_format_range` | Format a specific range | -| 📦 `vscode_organize_imports` | Organize imports in a document | -| ✏️ `vscode_rename_symbol` | Rename a symbol across the workspace | -| 🔎 `vscode_workspace_file_search` | Search for files by pattern | -| 📄 `vscode_workspace_text_search` | Search for text across files | +| 📂 `open_folder` | Open a workspace folder | +| 📋 `get_open_folders` | Get currently open workspace folder(s) | +| 🏷️ `document_symbols` | Get all symbols in a document | +| 🔍 `workspace_symbols` | Search symbols across the workspace | +| 🎯 `go_to_definition` | Find symbol definitions | +| 🔗 `find_references` | Find all references to a symbol | +| 💡 `hover_info` | Get type info and documentation | +| ⚠️ `diagnostics` | Get errors and warnings | +| 📞 `call_hierarchy` | Get incoming/outgoing calls | +| ✏️ `get_completions` | Get code completions at a position | +| 📝 `get_signature_help` | Get function signature help | +| 🏗️ `get_type_hierarchy` | Get type hierarchy information | +| 🔧 `get_code_actions` | Get available code actions/quick fixes | +| 🎯 `get_document_highlights` | Find symbol highlights in a document | +| 🧩 `get_folding_ranges` | Get collapsible regions in a document | +| 🧷 `get_inlay_hints` | Get inlay hints for a range | +| 🧠 `get_semantic_tokens` | Get semantic tokens for syntax understanding | +| 🔎 `get_code_lens` | Get code lens entries for a document | +| 🔗 `get_document_links` | Get clickable links in a document | +| 🪄 `get_selection_range` | Get semantic selection ranges | +| 🎨 `get_document_colors` | Get color information from a document | +| 🔎 `search_workspace_files` | Search for files by pattern | +| 📄 `search_workspace_text` | Search for text across files | +| 🎨 `format_document` | Format an entire document | +| ✂️ `format_range` | Format a specific range | +| 📦 `organize_imports` | Organize imports in a document | +| ✏️ `rename_symbol` | Rename a symbol across the workspace | +| 🛠️ `apply_code_action` | Apply a specific code action (supports dry-run) | +| 🧰 `text_editor` | File ops: view/replace/insert/create/undo | +| 📁 `list_directory` | List directory contents as a tree | +| 🎯 `focus_editor` | Open a file and focus a specific range | +| 🐞 `list_debug_sessions` | List active debug sessions | +| ▶️ `start_debug_session` | Start a debug session from a JSON configuration | +| 🔄 `restart_debug_session` | Restart a debug session by id | +| ⏹️ `stop_debug_session` | Stop a debug session by id or stop all | +| 🧾 `list_vscode_commands` | List available VS Code command ids | +| 🧪 `execute_vscode_command` | Execute a VS Code command (unsafe; gated) | +| 🖥️ `execute_command` | Execute a shell command (unsafe; gated) | +| 📟 `get_terminal_output` | Get output for an `execute_command` process id | +| 🌐 `preview_url` | Open a URL in VS Code or externally | --- @@ -92,6 +113,89 @@ Add to your MCP client configuration: } ``` +### ChatGPT Web (ngrok + Google OAuth) + +If you want to expose your MCP endpoint to ChatGPT Web, use ngrok as an OAuth-protected proxy in front of the local server. + +High-level flow: +- VS Code extension runs locally: `http://127.0.0.1:4000/mcp` +- ngrok enforces Google OAuth (Traffic Policy `oauth` action) +- ngrok forwards requests upstream to your local MCP server + +#### Full example (Google OAuth + upstream bearer token) + +This example keeps the extension secure by requiring a bearer token, and configures ngrok to: +1) enforce Google OAuth for end users, and +2) inject the bearer token when forwarding upstream to the local MCP server. + +1) Configure the extension token (VS Code Settings) + +Set a strong token in VS Code settings: +- `codingwithcalvin.mcp.authToken`: `your-long-random-token` + +2) Start the MCP server in VS Code + +Run `MCP Server: Start`. Confirm locally: + +```bash +curl -sS -H "Authorization: Bearer your-long-random-token" http://127.0.0.1:4000/health +``` + +3) Create an ngrok Traffic Policy + +Create `policy.yml`: + +```yaml +on_http_request: + - actions: + - type: oauth + config: + provider: google + allow_cors_preflight: true + - type: "add-headers" + config: + headers: + authorization: "Bearer your-long-random-token" +``` + +4) Start ngrok to your local MCP server + +```bash +ngrok http 127.0.0.1:4000 --traffic-policy-file=policy.yml +``` + +5) Test through the tunnel + +Open `https:///health` in a browser and complete Google sign-in. You should see: + +```json +{"status":"ok","port":4000} +``` + +Your MCP endpoint via ngrok is: + +``` +https:///mcp +``` + +In VS Code, you can also run `MCP Server: Connection Info` to see/copy the ngrok URL if the ngrok local dashboard is available at `http://127.0.0.1:4040`. + +#### Custom Google OAuth app (optional) + +If you want to use your own Google OAuth client: +- Create OAuth client credentials in Google Cloud. +- Set the Redirect/Callback URL to: + +``` +https://idp.ngrok.com/oauth2/callback +``` + +Then add `client_id` and `client_secret` under the `oauth` action config in `policy.yml` (see ngrok docs). + +ngrok docs: +- `https://ngrok.com/docs/traffic-policy/actions/oauth` +- `https://ngrok.com/docs/traffic-policy/actions/add-headers` + ### URI Protocol Launch VS Code and control the MCP server via URI: @@ -111,6 +215,9 @@ vscode://codingwithcalvin.mcp/open?folder=/path/to/dir # Open folder and start | `codingwithcalvin.mcp.autoStart` | `true` | 🚀 Auto-start server on VS Code launch | | `codingwithcalvin.mcp.port` | `4000` | 🔌 MCP server port | | `codingwithcalvin.mcp.bindAddress` | `127.0.0.1` | 🔒 Bind address (localhost only) | +| `codingwithcalvin.mcp.allowRemoteConnections` | `false` | ⚠️ Allow non-local Host/Origin headers (for tunnels like ngrok). Requires `authToken`. | +| `codingwithcalvin.mcp.authToken` | `""` | 🔑 Optional bearer token. If set, clients must send `Authorization: Bearer `. | +| `codingwithcalvin.mcp.enableUnsafeTools` | `false` | ⚠️ Enable unsafe tools like `execute_command` and `execute_vscode_command` | --- @@ -130,6 +237,7 @@ Access these from the Command Palette (Ctrl+Shift+P): - 🏠 **Localhost Only** - Binds only to `127.0.0.1` - 🛡️ **DNS Rebinding Protection** - Validates Host header - ✅ **Same-machine Trusted** - No authentication required for local access +- ⚠️ **Tunnels/Remote** - If using ngrok, enable `codingwithcalvin.mcp.allowRemoteConnections` and set `codingwithcalvin.mcp.authToken` --- diff --git a/package-lock.json b/package-lock.json index afd6232..0b2b9be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "codingwithcalvin-mcpserver", + "name": "VSC-MCPServer", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "codingwithcalvin-mcpserver", + "name": "VSC-MCPServer", "version": "0.1.0", - "license": "MIT", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "express": "^4.18.2", @@ -1809,7 +1809,6 @@ "integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1917,7 +1916,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2189,7 +2187,6 @@ "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "1.6.1", "fast-glob": "^3.3.2", @@ -2258,7 +2255,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2800,7 +2796,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3133,7 +3128,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3532,7 +3526,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6410,7 +6403,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7009,7 +7001,6 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -7253,7 +7244,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b479fe4..7bc37a0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "engines": { "vscode": "^1.85.0" }, + "enabledApiProposals": ["findTextInFiles"], "categories": [ "Other", "Programming Languages" @@ -60,9 +61,25 @@ "command": "codingwithcalvin.mcp.restart", "title": "MCP Server: Restart" }, + { + "command": "codingwithcalvin.mcp.connectionInfo", + "title": "MCP Server: Connection Info" + }, { "command": "codingwithcalvin.mcp.showTools", "title": "MCP Server: Show Available Tools" + }, + { + "command": "codingwithcalvin.mcp.inspectLmTools", + "title": "MCP Server: Inspect vscode.lm.tools" + }, + { + "command": "codingwithcalvin.mcp.configureVscodeTools", + "title": "MCP Server: Configure VS Code Tools…" + }, + { + "command": "codingwithcalvin.mcp.configureDefaultTools", + "title": "MCP Server: Configure Built-in Tools…" } ], "configuration": { @@ -82,6 +99,41 @@ "type": "string", "default": "127.0.0.1", "description": "Address to bind the MCP server (localhost only for security)" + }, + "codingwithcalvin.mcp.allowRemoteConnections": { + "type": "boolean", + "default": false, + "description": "Allow non-local Host/Origin headers (required for tunnels like ngrok). Requires authToken for safety." + }, + "codingwithcalvin.mcp.authToken": { + "type": "string", + "default": "", + "description": "Optional bearer token for HTTP requests. If set, clients must send: Authorization: Bearer ." + }, + "codingwithcalvin.mcp.enableUnsafeTools": { + "type": "boolean", + "default": false, + "description": "Enable unsafe tools like execute_command and execute_vscode_command. WARNING: these allow arbitrary command execution." + }, + "codingwithcalvin.mcp.useFindTextInFiles": { + "type": "boolean", + "default": true, + "description": "Use VS Code's built-in findTextInFiles API for search_workspace_text when available (fast). If disabled or unavailable, a slower fallback search is used." + }, + "codingwithcalvin.mcp.autoSaveAfterToolEdits": { + "type": "boolean", + "default": true, + "description": "Automatically save files to disk after edit-applying tools run (format_document, format_range, organize_imports, rename_symbol, apply_code_action)." + }, + "codingwithcalvin.mcp.defaultTools.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Expose this extension's built-in tools through the MCP server. Configure which tools are exposed via [MCP Server: Configure Built-in Tools…](command:codingwithcalvin.mcp.configureDefaultTools)." + }, + "codingwithcalvin.mcp.vscodeTools.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Expose VS Code tools (vscode.lm.tools, including tools coming from VS Code MCP servers / extensions) through this MCP server. Select tools via [MCP Server: Configure VS Code Tools…](command:codingwithcalvin.mcp.configureVscodeTools)." } } } diff --git a/src/adapters/vscodeAdapter.ts b/src/adapters/vscodeAdapter.ts index ee6c5e6..f286ba8 100644 --- a/src/adapters/vscodeAdapter.ts +++ b/src/adapters/vscodeAdapter.ts @@ -116,6 +116,7 @@ export async function ensureDocumentOpen(uri: vscode.Uri): Promise('defaultTools.allowed') + : undefined; + const defaultToolsAllowedConfigured = + defaultAllowedInspect?.workspaceFolderValue !== undefined || + defaultAllowedInspect?.workspaceValue !== undefined || + defaultAllowedInspect?.globalValue !== undefined; return { autoStart: config.get('autoStart', true), port: config.get('port', 4000), bindAddress: config.get('bindAddress', '127.0.0.1'), + allowRemoteConnections: config.get('allowRemoteConnections', false), + authToken: config.get('authToken', ''), + enableUnsafeTools: config.get('enableUnsafeTools', false), + useFindTextInFiles: config.get('useFindTextInFiles', true), + autoSaveAfterToolEdits: config.get('autoSaveAfterToolEdits', true), + enableDefaultTools: config.get('defaultTools.enabled', true), + enableVSCodeTools: config.get('vscodeTools.enabled', false), + vscodeToolsAllowedNames: config.get('vscodeTools.allowed', []), + defaultToolsAllowedNames: config.get('defaultTools.allowed', []), + defaultToolsAllowedConfigured, }; } diff --git a/src/extension.ts b/src/extension.ts index 1639021..b050501 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,10 @@ import * as vscode from 'vscode'; import { MCPServer } from './server'; import { getConfiguration, onConfigurationChanged, MCPServerConfig } from './config/settings'; import { MCPUriHandler } from './handlers/uriHandler'; -import { getAllTools } from './tools'; +import { getAllTools, getBuiltInToolsCatalog } from './tools'; +import { getNgrokPublicUrl } from './utils/ngrok'; +import { initDebugSessionRegistry } from './utils/debugSessionRegistry'; +import { getLanguageModelToolsSnapshot, groupToolNamesByPrefix } from './utils/lmTools'; let mcpServer: MCPServer | undefined; let statusBarItem: vscode.StatusBarItem | undefined; @@ -14,6 +17,8 @@ export async function activate(context: vscode.ExtensionContext): Promise const config = getConfiguration(); + initDebugSessionRegistry(context); + // Create status bar item statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBarItem.command = 'codingwithcalvin.mcp.toggle'; @@ -25,7 +30,11 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand('codingwithcalvin.mcp.stop', stopServer), vscode.commands.registerCommand('codingwithcalvin.mcp.restart', restartServer), vscode.commands.registerCommand('codingwithcalvin.mcp.toggle', toggleServer), - vscode.commands.registerCommand('codingwithcalvin.mcp.showTools', showAvailableTools) + vscode.commands.registerCommand('codingwithcalvin.mcp.connectionInfo', showConnectionInfo), + vscode.commands.registerCommand('codingwithcalvin.mcp.showTools', showAvailableTools), + vscode.commands.registerCommand('codingwithcalvin.mcp.inspectLmTools', inspectLanguageModelTools), + vscode.commands.registerCommand('codingwithcalvin.mcp.configureVscodeTools', configureVSCodeTools), + vscode.commands.registerCommand('codingwithcalvin.mcp.configureDefaultTools', configureBuiltInTools) ); // Register URI handler @@ -135,8 +144,8 @@ async function toggleServer(): Promise { } async function showAvailableTools(): Promise { - const tools = getAllTools(); const config = getConfiguration(); + const tools = getAllTools(config); const port = mcpServer?.getPort() || config.port; const isRunning = mcpServer?.getIsRunning() || false; @@ -167,6 +176,74 @@ async function showAvailableTools(): Promise { } } +async function showConnectionInfo(): Promise { + const config = getConfiguration(); + const port = mcpServer?.getPort() || config.port; + const isRunning = mcpServer?.getIsRunning() || false; + const localUrl = `http://127.0.0.1:${port}/mcp`; + + let ngrokPublicUrl: string | undefined; + try { + ngrokPublicUrl = await getNgrokPublicUrl('http://127.0.0.1:4040', 750, port); + } catch { + ngrokPublicUrl = undefined; + } + + const items: vscode.QuickPickItem[] = [ + { + label: 'Local (direct)', + kind: vscode.QuickPickItemKind.Separator, + }, + { + label: `$(globe) ${localUrl}`, + description: isRunning ? 'Copy to clipboard' : 'Server not running', + }, + { + label: 'ngrok (optional)', + kind: vscode.QuickPickItemKind.Separator, + }, + ngrokPublicUrl + ? { + label: `$(link-external) ${ngrokPublicUrl}/mcp`, + description: 'Copy to clipboard', + } + : { + label: 'ngrok not detected', + description: `Start ngrok for port ${port} and refresh`, + }, + { + label: '$(browser) Open ngrok dashboard (localhost:4040)', + description: 'Shows your public URL', + }, + ]; + + const selected = await vscode.window.showQuickPick(items, { + title: 'MCP Server Connection Info', + placeHolder: 'Select an item to copy/open', + }); + + if (!selected) { + return; + } + + if (selected.label.includes('http://127.0.0.1:')) { + await vscode.env.clipboard.writeText(localUrl); + vscode.window.showInformationMessage(`Copied: ${localUrl}`); + return; + } + + if (ngrokPublicUrl && selected.label.includes(ngrokPublicUrl)) { + const url = `${ngrokPublicUrl}/mcp`; + await vscode.env.clipboard.writeText(url); + vscode.window.showInformationMessage(`Copied: ${url}`); + return; + } + + if (selected.label.includes('ngrok dashboard')) { + await vscode.env.openExternal(vscode.Uri.parse('http://127.0.0.1:4040')); + } +} + function updateStatusBar(running: boolean, port?: number): void { if (!statusBarItem) { return; @@ -189,3 +266,165 @@ function log(message: string): void { const timestamp = new Date().toISOString(); outputChannel?.appendLine(`[${timestamp}] ${message}`); } + +async function inspectLanguageModelTools(): Promise { + const snapshot = getLanguageModelToolsSnapshot(); + + if (!vscode.lm) { + vscode.window.showWarningMessage('vscode.lm is not available in this VS Code build.'); + return; + } + + if (snapshot.length === 0) { + vscode.window.showInformationMessage('No tools found in vscode.lm.tools.'); + return; + } + + const groups = groupToolNamesByPrefix(snapshot.map((t) => t.name)); + + const items: vscode.QuickPickItem[] = [ + { + label: 'Copy all tools as JSON', + description: `${snapshot.length} tools`, + }, + { + label: 'Copy grouped tool names (by prefix)', + description: `${groups.size} groups`, + }, + { label: 'Tools', kind: vscode.QuickPickItemKind.Separator }, + ...snapshot.map((tool) => ({ + label: tool.name, + description: tool.tags.length > 0 ? tool.tags.join(', ') : undefined, + detail: tool.description || (tool.hasInputSchema ? `inputSchema: ${tool.inputSchemaType ?? 'present'}` : 'no inputSchema'), + })), + ]; + + const selected = await vscode.window.showQuickPick(items, { + title: 'vscode.lm.tools', + matchOnDescription: true, + matchOnDetail: true, + placeHolder: 'Select a tool to copy its name, or pick an export option', + }); + + if (!selected) { + return; + } + + if (selected.label === 'Copy all tools as JSON') { + await vscode.env.clipboard.writeText(JSON.stringify(snapshot, null, 2)); + vscode.window.showInformationMessage(`Copied ${snapshot.length} tools as JSON`); + log(`Copied ${snapshot.length} vscode.lm.tools as JSON`); + return; + } + + if (selected.label === 'Copy grouped tool names (by prefix)') { + const grouped = Object.fromEntries([...groups.entries()]); + await vscode.env.clipboard.writeText(JSON.stringify(grouped, null, 2)); + vscode.window.showInformationMessage(`Copied ${groups.size} groups as JSON`); + log(`Copied vscode.lm.tools grouped by prefix (${groups.size} groups)`); + return; + } + + await vscode.env.clipboard.writeText(selected.label); + vscode.window.showInformationMessage(`Copied: ${selected.label}`); +} + +async function configureVSCodeTools(): Promise { + const config = getConfiguration(); + + if (!vscode.lm?.tools) { + vscode.window.showWarningMessage('vscode.lm.tools is not available in this VS Code build.'); + return; + } + + if (!config.enableVSCodeTools) { + const enable = await vscode.window.showInformationMessage( + 'VS Code tools are currently disabled. Enable them first (codingwithcalvin.mcp.vscodeTools.enabled).', + 'Open Settings' + ); + if (enable === 'Open Settings') { + await vscode.commands.executeCommand('workbench.action.openSettings', 'codingwithcalvin.mcp.vscodeTools'); + } + return; + } + + const tools = Array.from(vscode.lm.tools).sort((a, b) => a.name.localeCompare(b.name)); + const current = new Set(config.vscodeToolsAllowedNames); + + const items: (vscode.QuickPickItem & { toolName: string })[] = tools.map((tool) => ({ + label: tool.name, + description: tool.tags?.length ? tool.tags.join(', ') : undefined, + detail: tool.description || undefined, + picked: current.has(tool.name), + toolName: tool.name, + })); + + const selected = await vscode.window.showQuickPick(items, { + title: 'Expose VS Code Tools', + placeHolder: 'Select which vscode.lm.tools to expose via MCP', + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!selected) { + return; + } + + const allowed = selected.map((s) => s.toolName); + await vscode.workspace + .getConfiguration('codingwithcalvin.mcp') + .update('vscodeTools.allowed', allowed, vscode.ConfigurationTarget.Workspace); + + vscode.window.showInformationMessage(`Exposing ${allowed.length} VS Code tool(s) via MCP`); + log(`Updated vscodeTools.allowed (${allowed.length} tools)`); +} + +async function configureBuiltInTools(): Promise { + const config = getConfiguration(); + + if (config.enableDefaultTools === false) { + const enable = await vscode.window.showInformationMessage( + 'Built-in tools are currently disabled (codingwithcalvin.mcp.defaultTools.enabled).', + 'Open Settings' + ); + if (enable === 'Open Settings') { + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'codingwithcalvin.mcp.defaultTools' + ); + } + return; + } + + const catalog = getBuiltInToolsCatalog(); + const current = config.defaultToolsAllowedConfigured + ? new Set(config.defaultToolsAllowedNames) + : new Set(catalog.map((t) => t.name)); + + const items: (vscode.QuickPickItem & { toolName: string })[] = catalog.map((tool) => ({ + label: tool.name, + detail: tool.description, + picked: current.has(tool.name), + toolName: tool.name, + })); + + const selected = await vscode.window.showQuickPick(items, { + title: 'Expose Built-in Tools', + placeHolder: 'Select which built-in tools to expose via MCP (empty = expose none)', + canPickMany: true, + matchOnDetail: true, + }); + + if (!selected) { + return; + } + + const allowed = selected.map((s) => s.toolName); + await vscode.workspace + .getConfiguration('codingwithcalvin.mcp') + .update('defaultTools.allowed', allowed, vscode.ConfigurationTarget.Workspace); + + vscode.window.showInformationMessage(`Exposing ${allowed.length} built-in tool(s) via MCP`); + log(`Updated defaultTools.allowed (${allowed.length} tools)`); +} diff --git a/src/server/mcpServer.test.ts b/src/server/mcpServer.test.ts new file mode 100644 index 0000000..36f9200 --- /dev/null +++ b/src/server/mcpServer.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; +import * as http from 'http'; + +// Mock vscode module - must be before imports that use vscode indirectly via tools +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { resetMocks } from '../test/helpers/mockVscode'; + +let MCPServer: typeof import('./mcpServer').MCPServer; + +type HttpResponse = { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +}; + +function httpRequest(options: { + port: number; + method: string; + path: string; + headers?: Record; + body?: unknown; +}): Promise { + return new Promise((resolve, reject) => { + const request = http.request( + { + hostname: '127.0.0.1', + port: options.port, + method: options.method, + path: options.path, + headers: options.headers, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => { + resolve({ + status: response.statusCode || 0, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + } + ); + + request.on('error', reject); + + if (options.body !== undefined) { + const payload = JSON.stringify(options.body); + if (!request.getHeader('Content-Type')) { + request.setHeader('Content-Type', 'application/json'); + } + request.setHeader('Content-Length', Buffer.byteLength(payload)); + request.write(payload); + } + + request.end(); + }); +} + +function httpGetHeaders(options: { + port: number; + path: string; + headers?: Record; +}): Promise> { + return new Promise((resolve, reject) => { + const request = http.request( + { + hostname: '127.0.0.1', + port: options.port, + method: 'GET', + path: options.path, + headers: options.headers, + }, + (response) => { + resolve({ + status: response.statusCode || 0, + headers: response.headers, + }); + response.destroy(); + request.destroy(); + } + ); + + request.on('error', reject); + request.end(); + }); +} + +describe('MCPServer HTTP middleware', () => { + beforeAll(async () => { + ({ MCPServer } = await import('./mcpServer')); + }); + + beforeEach(() => { + resetMocks(); + }); + + it('allows localhost Host header by default', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ port, method: 'GET', path: '/health' }); + expect(res.status).toBe(200); + } finally { + await server.stop(); + } + }); + + it('rejects non-local Host header by default (403)', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'GET', + path: '/health', + headers: { Host: 'example.ngrok-free.app' }, + }); + expect(res.status).toBe(403); + } finally { + await server.stop(); + } + }); + + it('fails to start when remote connections enabled without authToken', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: true, + authToken: '', + }); + + await expect(server.start()).rejects.toThrow(/auth token/i); + }); + + it('requires bearer token when authToken is set (401)', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: true, + authToken: 'secret-token', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'GET', + path: '/health', + headers: { Host: 'example.ngrok-free.app' }, + }); + expect(res.status).toBe(401); + } finally { + await server.stop(); + } + }); + + it('accepts remote Host header when allowRemoteConnections enabled and token matches (200)', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: true, + authToken: 'secret-token', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'GET', + path: '/health', + headers: { + Host: 'example.ngrok-free.app', + Authorization: 'Bearer secret-token', + }, + }); + expect(res.status).toBe(200); + } finally { + await server.stop(); + } + }); + + it('accepts remote Host header with valid token even when allowRemoteConnections is disabled (200)', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: 'secret-token', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'GET', + path: '/health', + headers: { + Host: 'example.ngrok-free.app', + Authorization: 'Bearer secret-token', + }, + }); + expect(res.status).toBe(200); + } finally { + await server.stop(); + } + }); + + it('allows CORS preflight (OPTIONS) without auth and advertises Authorization header', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: true, + authToken: 'secret-token', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'OPTIONS', + path: '/mcp', + headers: { + Host: 'example.ngrok-free.app', + Origin: 'https://example.ngrok-free.app', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type,authorization', + }, + }); + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-headers']).toMatch(/authorization/i); + expect(res.headers['access-control-allow-origin']).toBe('https://example.ngrok-free.app'); + } finally { + await server.stop(); + } + }); +}); + +describe('MCPServer /mcp transport', () => { + beforeAll(async () => { + ({ MCPServer } = await import('./mcpServer')); + }); + + beforeEach(() => { + resetMocks(); + }); + + it('accepts initialize over JSON-RPC HTTP', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'POST', + path: '/mcp', + headers: { 'Content-Type': 'application/json-rpc' }, + body: { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }, + }, + }); + expect(res.status).toBe(200); + const parsed = JSON.parse(res.body); + expect(parsed.jsonrpc).toBe('2.0'); + expect(parsed.id).toBe(1); + expect(parsed.result?.serverInfo?.name).toBeTruthy(); + } finally { + await server.stop(); + } + }); + + it('supports JSON-RPC batch requests', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'POST', + path: '/mcp', + body: [ + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + }, + }, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + ], + }); + expect(res.status).toBe(200); + const parsed = JSON.parse(res.body); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + const ids = parsed.map((item: { id: number }) => item.id).sort(); + expect(ids).toEqual([1, 2]); + } finally { + await server.stop(); + } + }); + + it('accepts header-based payloads via cf-mcp-message', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpRequest({ + port, + method: 'POST', + path: '/mcp', + headers: { + 'cf-mcp-message': JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }, + }); + expect(res.status).toBe(200); + const parsed = JSON.parse(res.body); + expect(parsed.id).toBe(1); + expect(parsed.result?.tools).toBeTruthy(); + } finally { + await server.stop(); + } + }); + + it('supports GET /mcp for SSE (responds with event-stream)', async () => { + const server = new MCPServer({ + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', + allowRemoteConnections: false, + authToken: '', + }); + + const port = await server.start(); + try { + const res = await httpGetHeaders({ + port, + path: '/mcp', + headers: { Accept: 'text/event-stream' }, + }); + expect(res.status).toBe(200); + expect(String(res.headers['content-type'] || '')).toMatch(/text\/event-stream/i); + } finally { + await server.stop(); + } + }); +}); diff --git a/src/server/mcpServer.ts b/src/server/mcpServer.ts index 4f6303e..0d28062 100644 --- a/src/server/mcpServer.ts +++ b/src/server/mcpServer.ts @@ -1,7 +1,7 @@ import * as http from 'http'; import express, { Request, Response, NextFunction } from 'express'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -11,6 +11,7 @@ import { getAllTools, callTool } from '../tools'; export class MCPServer { private server: Server; + private transport: StreamableHTTPServerTransport | undefined; private httpServer: http.Server | undefined; private app: express.Application; private isRunning: boolean = false; @@ -34,18 +35,72 @@ export class MCPServer { this.setupMCPHandlers(); } + private shouldRequireAuth(): boolean { + return this.config.allowRemoteConnections || this.config.authToken.trim().length > 0; + } + + private validateAuthConfiguration(): void { + if (this.config.allowRemoteConnections && this.config.authToken.trim().length === 0) { + throw new Error( + 'Remote connections are enabled but no auth token is configured. Set codingwithcalvin.mcp.authToken.' + ); + } + } + private setupMiddleware(): void { - // Parse JSON bodies - this.app.use(express.json()); + // Robust request body parsing for /mcp: accept JSON even when clients use non-standard content-types. + // We intentionally parse as raw bytes and decode ourselves so we can also fall back to header-based payloads. + this.app.use('/mcp', express.raw({ type: '*/*', limit: '10mb' })); + + // Parse JSON bodies for non-MCP endpoints (e.g. future config routes). + this.app.use(express.json({ limit: '2mb' })); + + // Optional bearer token auth (recommended for tunnels) + this.app.use((req: Request, res: Response, next: NextFunction) => { + if (req.method === 'OPTIONS') { + next(); + return; + } - // DNS rebinding protection - only allow localhost + if (!this.shouldRequireAuth()) { + next(); + return; + } + + const expected = this.config.authToken.trim(); + const authorization = req.header('authorization') || ''; + const match = authorization.match(/^Bearer\s+(.+)$/i); + const provided = match?.[1]?.trim() || ''; + + if (!expected || provided !== expected) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + (req as unknown as { __mcpAuthOk?: boolean }).__mcpAuthOk = true; + next(); + }); + + // DNS rebinding protection - only allow localhost unless explicitly enabled this.app.use((req: Request, res: Response, next: NextFunction) => { + if ((req as unknown as { __mcpAuthOk?: boolean }).__mcpAuthOk) { + next(); + return; + } + + if (this.config.allowRemoteConnections) { + next(); + return; + } + const host = req.headers.host || ''; if ( !host.startsWith('localhost:') && !host.startsWith('127.0.0.1:') && + !host.startsWith('[::1]:') && host !== 'localhost' && - host !== '127.0.0.1' + host !== '127.0.0.1' && + host !== '[::1]' ) { res.status(403).json({ error: 'Forbidden: Invalid host header' }); return; @@ -53,16 +108,21 @@ export class MCPServer { next(); }); - // CORS for localhost only + // CORS for localhost only unless explicitly enabled this.app.use((req: Request, res: Response, next: NextFunction) => { const origin = req.headers.origin || ''; if ( !origin || - origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) + this.config.allowRemoteConnections || + origin.match(/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/) ) { res.header('Access-Control-Allow-Origin', origin || '*'); - res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id'); + res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.header( + 'Access-Control-Allow-Headers', + 'Content-Type, mcp-session-id, mcp-protocol-version, cf-mcp-message, Authorization' + ); + res.header('Vary', 'Origin'); } if (req.method === 'OPTIONS') { res.sendStatus(200); @@ -76,7 +136,7 @@ export class MCPServer { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: getAllTools(), + tools: getAllTools(this.config), }; }); @@ -84,7 +144,7 @@ export class MCPServer { this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { - const result = await callTool(name, args || {}); + const result = await callTool(name, args || {}, this.config); return { content: [ { @@ -114,77 +174,176 @@ export class MCPServer { res.json({ status: 'ok', port: this.actualPort }); }); - // MCP endpoint - simplified JSON-RPC over HTTP - this.app.post('/mcp', async (req: Request, res: Response) => { - try { - const { method, params, id } = req.body; + // MCP endpoint - MCP Streamable HTTP transport (supports POST JSON-RPC and GET SSE). + this.app.all('/mcp', async (req: Request, res: Response) => { + if (!this.transport) { + res.status(503).json({ error: 'MCP transport not initialized' }); + return; + } + + if (req.method !== 'GET' && req.method !== 'POST' && req.method !== 'DELETE') { + res.status(405).json({ error: `Method not allowed: ${req.method}` }); + return; + } + const parsedBody = this.getParsedMcpBody(req, res); + if (parsedBody === undefined && req.method === 'POST') { + return; + } + + if (req.method === 'POST') { + // The Streamable HTTP transport is strict about Accept/Content-Type headers. + // For interoperability (older clients, some proxies, cf-mcp-message), fall back to legacy JSON-RPC when + // headers aren't compliant. + const accept = String(req.headers['accept'] || ''); + const contentType = String(req.headers['content-type'] || ''); + const isStreamableCompliant = + accept.includes('application/json') && + accept.includes('text/event-stream') && + contentType.includes('application/json'); + + if (!isStreamableCompliant) { + await this.handleLegacyJsonRpcOverHttp(parsedBody, res); + return; + } + } + + await this.transport.handleRequest(req, res, parsedBody); + }); + } + + private async handleLegacyJsonRpcOverHttp(payload: unknown, res: Response): Promise { + const handleOne = async (message: unknown): Promise => { + const body = message as { method?: unknown; params?: unknown; id?: unknown; jsonrpc?: unknown }; + const id = body?.id as unknown; + + // Notification: do not return a response. + if (id === undefined || id === null) { + return undefined; + } + + const method = typeof body?.method === 'string' ? body.method : ''; + const params = (body?.params ?? {}) as unknown; + + try { if (method === 'initialize') { - res.json({ + return { jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', - capabilities: { - tools: {}, - }, - serverInfo: { - name: 'vscode-mcp', - version: '0.1.0', - }, + capabilities: { tools: {} }, + serverInfo: { name: 'vscode-mcp', version: '0.1.0' }, }, - }); - return; + }; } if (method === 'tools/list') { - const tools = getAllTools(); - res.json({ + return { jsonrpc: '2.0', id, - result: { tools }, - }); - return; + result: { tools: getAllTools() }, + }; } if (method === 'tools/call') { - const { name, arguments: args } = params; - const result = await callTool(name, args || {}); - res.json({ + const callParams = params as { name?: unknown; arguments?: unknown }; + const name = typeof callParams?.name === 'string' ? callParams.name : ''; + const args = (callParams?.arguments ?? {}) as Record; + const result = await callTool(name, args); + return { jsonrpc: '2.0', id, result: { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }, - }); - return; + }; } - res.json({ + return { jsonrpc: '2.0', id, - error: { - code: -32601, - message: `Method not found: ${method}`, - }, - }); + error: { code: -32601, message: `Method not found: ${method}` }, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - res.json({ + return { jsonrpc: '2.0', - id: req.body?.id, - error: { - code: -32603, - message: errorMessage, - }, - }); + id, + error: { code: -32603, message: errorMessage }, + }; } - }); + }; + + if (Array.isArray(payload)) { + const responses = (await Promise.all(payload.map(handleOne))).filter( + (item): item is unknown => item !== undefined + ); + if (responses.length === 0) { + res.sendStatus(204); + return; + } + res.json(responses); + return; + } + + const response = await handleOne(payload); + if (response === undefined) { + res.sendStatus(204); + return; + } + res.json(response); + } + + private getParsedMcpBody(req: Request, res: Response): unknown | undefined { + if (req.method !== 'POST') { + return undefined; + } + + const headerPayload = req.header('cf-mcp-message'); + const rawBody = req.body; + + const candidates: Array = []; + if (rawBody && (typeof rawBody === 'string' || Buffer.isBuffer(rawBody))) { + candidates.push(rawBody); + } + if (headerPayload) { + candidates.push(headerPayload); + try { + candidates.push(decodeURIComponent(headerPayload)); + } catch { + // ignore + } + try { + const decoded = Buffer.from(headerPayload, 'base64').toString('utf8'); + candidates.push(decoded); + } catch { + // ignore + } + } + + for (const candidate of candidates) { + const text = Buffer.isBuffer(candidate) ? candidate.toString('utf8') : candidate; + if (!text || !text.trim()) { + continue; + } + try { + return JSON.parse(text); + } catch { + continue; + } + } + + if (candidates.length > 0) { + res.status(400).json({ + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }); + return undefined; + } + + return undefined; } async start(): Promise { @@ -192,6 +351,15 @@ export class MCPServer { return this.actualPort; } + this.validateAuthConfiguration(); + + if (!this.transport) { + this.transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await this.server.connect(this.transport); + } + this.setupRoutes(); return new Promise((resolve, reject) => { @@ -225,7 +393,7 @@ export class MCPServer { return; } - return new Promise((resolve) => { + await new Promise((resolve) => { this.httpServer?.close(() => { this.isRunning = false; this.actualPort = 0; @@ -233,6 +401,9 @@ export class MCPServer { resolve(); }); }); + + await this.transport?.close(); + this.transport = undefined; } async restart(): Promise { diff --git a/src/test/helpers/mockVscode.ts b/src/test/helpers/mockVscode.ts index 0fa5f17..5a4712f 100644 --- a/src/test/helpers/mockVscode.ts +++ b/src/test/helpers/mockVscode.ts @@ -16,6 +16,8 @@ export class MockRange { ) {} } +export class MockSelection extends MockRange {} + export class MockUri { constructor(public fsPath: string) {} @@ -40,6 +42,8 @@ export class MockTextDocument { private content: string ) {} + save = vi.fn().mockResolvedValue(true); + getText(): string { return this.content; } @@ -228,6 +232,7 @@ export class MockSemanticTokensLegend { export const mockVscode = { Position: MockPosition, Range: MockRange, + Selection: MockSelection, Uri: MockUri, TextEdit: MockTextEdit, WorkspaceEdit: MockWorkspaceEdit, @@ -342,25 +347,65 @@ export const mockVscode = { }, workspace: { + getConfiguration: vi.fn().mockImplementation(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + inspect: vi.fn((_key: string) => ({ + key: _key, + defaultValue: undefined, + globalValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + })), + update: vi.fn(), + })), openTextDocument: vi.fn().mockImplementation((uri: MockUri) => { return Promise.resolve(new MockTextDocument(uri, 'typescript', 1, '')); }), applyEdit: vi.fn().mockResolvedValue(true), workspaceFolders: [{ uri: MockUri.file('/test/workspace'), name: 'test-workspace' }], + getWorkspaceFolder: vi.fn(), findFiles: vi.fn(), findTextInFiles: vi.fn(), asRelativePath: vi.fn((uri: MockUri) => uri.fsPath.replace('/test/workspace/', '')), textDocuments: [], + fs: { + writeFile: vi.fn().mockResolvedValue(undefined), + }, }, commands: { executeCommand: vi.fn(), + getCommands: vi.fn().mockResolvedValue([]), }, window: { showInformationMessage: vi.fn(), showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), + showTextDocument: vi.fn().mockResolvedValue({ + selection: undefined, + revealRange: vi.fn(), + }), + }, + + env: { + openExternal: vi.fn().mockResolvedValue(true), + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }, + + TextEditorRevealType: { + InCenter: 0, + }, + + debug: { + sessions: [] as any[], + activeDebugSession: undefined as any, + onDidStartDebugSession: vi.fn().mockImplementation((_cb: any) => ({ dispose: vi.fn() })), + onDidTerminateDebugSession: vi.fn().mockImplementation((_cb: any) => ({ dispose: vi.fn() })), + startDebugging: vi.fn().mockResolvedValue(true), + stopDebugging: vi.fn().mockResolvedValue(undefined), }, }; @@ -369,4 +414,24 @@ export const mockVscode = { */ export function resetMocks() { vi.clearAllMocks(); + + // Ensure key mocks keep a safe default implementation after clearAllMocks. + mockVscode.workspace.getConfiguration.mockImplementation(() => ({ + get: vi.fn((_key: string, defaultValue: any) => defaultValue), + inspect: vi.fn((_key: string) => ({ + key: _key, + defaultValue: undefined, + globalValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + })), + update: vi.fn(), + })); + + mockVscode.workspace.openTextDocument.mockImplementation((uri: MockUri) => { + return Promise.resolve(new MockTextDocument(uri, 'typescript', 1, '')); + }); + + mockVscode.workspace.applyEdit.mockResolvedValue(true); + mockVscode.workspace.fs.writeFile.mockResolvedValue(undefined); } diff --git a/src/tools/applyCodeAction.ts b/src/tools/applyCodeAction.ts index e86a8de..b7fea2c 100644 --- a/src/tools/applyCodeAction.ts +++ b/src/tools/applyCodeAction.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import { rangeToJSON, ensureDocumentOpen } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; +import { saveUris } from '../utils/autoSave'; export const applyCodeActionSchema = z.object({ uri: z.string().describe('File URI or absolute file path'), @@ -36,14 +38,19 @@ export interface FileEdit { export async function applyCodeAction( params: z.infer -): Promise<{ success: boolean; changes?: FileEdit[]; message?: string }> { +): Promise<{ + success: boolean; + applied?: boolean; + saved?: boolean; + changes?: FileEdit[]; + message?: string; +}> { + const config = getConfiguration(); + // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); - } + const uri = params.uri.startsWith('file://') + ? vscode.Uri.parse(params.uri) + : vscode.Uri.file(params.uri); // Ensure document is open await ensureDocumentOpen(uri); @@ -78,9 +85,14 @@ export async function applyCodeAction( }; } + // vscode.executeCodeActionProvider can return either a Command or a CodeAction. + // - Command: { title, command: string, arguments?: any[] } + // - CodeAction: may have edit and/or command (Command object) + // Handle Command type actions - if ('command' in action && typeof action.command === 'string') { - // It's a Command - we can't preview these, and applying them might not be edit-based + if (!('kind' in action)) { + const commandAction = action as vscode.Command; + if (params.dryRun) { return { success: false, @@ -88,25 +100,25 @@ export async function applyCodeAction( }; } - // Execute the command - await vscode.commands.executeCommand(action.command, ...(action.arguments || [])); + await vscode.commands.executeCommand( + commandAction.command, + ...(commandAction.arguments || []) + ); return { success: true, + applied: true, + saved: false, message: `Executed command action "${params.actionTitle}"`, }; } - // Handle CodeAction type with edit - if ('kind' in action && action.edit) { - const workspaceEdit = action.edit; - const entries = workspaceEdit.entries(); + // Handle CodeAction + const codeAction = action as vscode.CodeAction; - if (entries.length === 0) { - return { - success: false, - message: 'Code action has no edits to apply', - }; - } + // If the action has edits, we can preview/apply them. + if (codeAction.edit) { + const workspaceEdit = codeAction.edit; + const entries = workspaceEdit.entries(); // Extract all changes for preview const fileEdits: FileEdit[] = []; @@ -128,42 +140,74 @@ export async function applyCodeAction( const totalEdits = fileEdits.reduce((sum, file) => sum + file.edits.length, 0); - // If dry-run, just return the changes without applying if (params.dryRun) { return { success: true, + applied: false, + saved: false, changes: fileEdits, message: `Dry-run: Would apply code action in ${fileEdits.length} file(s) with ${totalEdits} change(s)`, }; } - // Apply the edits const applied = await vscode.workspace.applyEdit(workspaceEdit); - - if (applied) { - // Execute associated command if present - if (action.command) { - await vscode.commands.executeCommand( - action.command.command, - ...(action.command.arguments || []) - ); - } - + if (!applied) { return { - success: true, - changes: fileEdits, - message: `Successfully applied code action in ${fileEdits.length} file(s) with ${totalEdits} change(s)`, + success: false, + message: 'Failed to apply code action edits', }; - } else { + } + + // Auto-save files if enabled + let saved = false; + if (config.autoSaveAfterToolEdits) { + const result = await saveUris(entries.map(([u]) => u)); + saved = result.failedUris.length === 0; + } + + // Execute associated command if present + if (codeAction.command) { + await vscode.commands.executeCommand( + codeAction.command.command, + ...(codeAction.command.arguments || []) + ); + } + + return { + success: true, + applied: true, + saved, + changes: fileEdits, + message: `Successfully applied code action in ${fileEdits.length} file(s) with ${totalEdits} change(s)${ + config.autoSaveAfterToolEdits ? (saved ? ' (saved)' : ' (save failed)') : '' + }`, + }; + } + + // Some code actions are command-only (no WorkspaceEdit). We can execute but cannot preview. + if (codeAction.command) { + if (params.dryRun) { return { success: false, - message: 'Failed to apply code action edits', + message: `Cannot preview code action "${params.actionTitle}" because it has no WorkspaceEdit (command-only action).`, }; } + + await vscode.commands.executeCommand( + codeAction.command.command, + ...(codeAction.command.arguments || []) + ); + + return { + success: true, + applied: true, + saved: false, + message: `Executed code action "${params.actionTitle}" (command-only)`, + }; } return { success: false, - message: 'Code action format not recognized', + message: 'Code action has neither edits nor an executable command', }; } diff --git a/src/tools/commandExecution.test.ts b/src/tools/commandExecution.test.ts new file mode 100644 index 0000000..bdd4e50 --- /dev/null +++ b/src/tools/commandExecution.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +vi.mock('child_process', () => { + return { spawn: vi.fn() }; +}); + +import { spawn } from 'child_process'; +import { executeCommand, executeCommandSchema, getTerminalOutput, getTerminalOutputSchema } from './commandExecution'; +import { mockVscode, resetMocks } from '../test/helpers/mockVscode'; + +function createMockChild() { + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + return child; +} + +describe('execute_command / get_terminal_output', () => { + beforeEach(() => { + resetMocks(); + (spawn as any).mockReset(); + }); + + it('blocks execute_command when unsafe tools disabled', async () => { + (mockVscode.workspace.getConfiguration as any).mockReturnValue({ + get: (key: string, defaultValue: any) => { + if (key === 'enableUnsafeTools') return false; + return defaultValue; + }, + update: vi.fn(), + }); + + const result = await executeCommand(executeCommandSchema.parse({ command: 'echo hi' })); + + expect(result.success).toBe(false); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('runs a command and captures output', async () => { + (mockVscode.workspace.getConfiguration as any).mockReturnValue({ + get: (key: string, defaultValue: any) => { + if (key === 'enableUnsafeTools') return true; + return defaultValue; + }, + update: vi.fn(), + }); + + const child = createMockChild(); + (spawn as any).mockReturnValue(child); + + const promise = executeCommand( + executeCommandSchema.parse({ command: 'echo hi', timeoutMs: 5000, background: false }) + ); + + // Simulate process output + exit. + child.stdout.emit('data', Buffer.from('hi\n')); + child.emit('exit', 0, null); + + const result = await promise; + + expect(result.success).toBe(true); + expect(result.stdout).toContain('hi'); + expect(result.id).toBeTruthy(); + + const out = await getTerminalOutput(getTerminalOutputSchema.parse({ id: result.id! })); + expect(out.success).toBe(true); + expect(out.stdout).toContain('hi'); + expect(out.running).toBe(false); + }); +}); + diff --git a/src/tools/commandExecution.ts b/src/tools/commandExecution.ts new file mode 100644 index 0000000..88c586e --- /dev/null +++ b/src/tools/commandExecution.ts @@ -0,0 +1,194 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; +import { randomUUID } from 'crypto'; +import { z } from 'zod'; +import { getConfiguration } from '../config/settings'; + +type ProcessState = { + id: string; + command: string; + cwd?: string; + startedAt: number; + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + truncated: boolean; +}; + +const MAX_OUTPUT_CHARS = 200_000; +const processes = new Map(); + +function appendOutput(state: ProcessState, key: 'stdout' | 'stderr', chunk: Buffer) { + const text = chunk.toString('utf8'); + state[key] += text; + + const excess = state[key].length - MAX_OUTPUT_CHARS; + if (excess > 0) { + state[key] = state[key].slice(excess); + state.truncated = true; + } +} + +function startProcess(command: string, cwd?: string): { + state: ProcessState; + child: ChildProcessWithoutNullStreams; +} { + const id = randomUUID(); + const state: ProcessState = { + id, + command, + cwd, + startedAt: Date.now(), + exitCode: null, + signal: null, + stdout: '', + stderr: '', + truncated: false, + }; + + const child = spawn(command, { + cwd, + shell: true, + stdio: 'pipe', + env: process.env, + }); + + child.stdout.on('data', (chunk: Buffer) => appendOutput(state, 'stdout', chunk)); + child.stderr.on('data', (chunk: Buffer) => appendOutput(state, 'stderr', chunk)); + child.on('exit', (code, signal) => { + state.exitCode = code; + state.signal = signal; + }); + + processes.set(id, state); + return { state, child }; +} + +export const executeCommandSchema = z.object({ + command: z.string().describe('Shell command to execute'), + cwd: z.string().optional().describe('Working directory to run the command in'), + timeoutMs: z.number().optional().default(60_000).describe('Timeout in milliseconds'), + background: z.boolean().optional().default(false).describe('Run command in background'), +}); + +export async function executeCommand(params: z.infer): Promise<{ + success: boolean; + id?: string; + exitCode?: number | null; + signal?: string | null; + stdout?: string; + stderr?: string; + truncated?: boolean; + message?: string; +}> { + const config = getConfiguration(); + if (!config.enableUnsafeTools) { + return { + success: false, + message: + 'Unsafe tools are disabled. Enable codingwithcalvin.mcp.enableUnsafeTools to use execute_command.', + }; + } + + const { state, child } = startProcess(params.command, params.cwd); + + if (params.background) { + return { + success: true, + id: state.id, + exitCode: state.exitCode, + signal: state.signal, + stdout: state.stdout, + stderr: state.stderr, + truncated: state.truncated, + message: 'Command started in background', + }; + } + + const timedOut = await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(true); + }, params.timeoutMs); + + child.on('exit', () => { + clearTimeout(timer); + resolve(false); + }); + }); + + if (timedOut && state.exitCode === null) { + child.kill('SIGTERM'); + return { + success: false, + id: state.id, + exitCode: state.exitCode, + signal: state.signal, + stdout: state.stdout, + stderr: state.stderr, + truncated: state.truncated, + message: `Command timed out after ${params.timeoutMs}ms`, + }; + } + + return { + success: true, + id: state.id, + exitCode: state.exitCode, + signal: state.signal, + stdout: state.stdout, + stderr: state.stderr, + truncated: state.truncated, + }; +} + +export const getTerminalOutputSchema = z.object({ + id: z.string().describe('Process id returned by execute_command'), + clear: z.boolean().optional().default(false).describe('Clear stored output after reading'), +}); + +export async function getTerminalOutput(params: z.infer): Promise<{ + success: boolean; + id: string; + running: boolean; + exitCode: number | null; + signal: string | null; + stdout: string; + stderr: string; + truncated: boolean; + message?: string; +}> { + const state = processes.get(params.id); + if (!state) { + return { + success: false, + id: params.id, + running: false, + exitCode: null, + signal: null, + stdout: '', + stderr: '', + truncated: false, + message: 'Unknown process id', + }; + } + + const result = { + success: true, + id: state.id, + running: state.exitCode === null && state.signal === null, + exitCode: state.exitCode, + signal: state.signal, + stdout: state.stdout, + stderr: state.stderr, + truncated: state.truncated, + }; + + if (params.clear) { + state.stdout = ''; + state.stderr = ''; + state.truncated = false; + } + + return result; +} + diff --git a/src/tools/debugSessions.test.ts b/src/tools/debugSessions.test.ts new file mode 100644 index 0000000..8d9715c --- /dev/null +++ b/src/tools/debugSessions.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { + listDebugSessions, + startDebugSession, + stopDebugSession, + restartDebugSession, + startDebugSessionSchema, + stopDebugSessionSchema, + restartDebugSessionSchema, +} from './debugSessions'; +import { mockVscode, resetMocks } from '../test/helpers/mockVscode'; + +describe('Debug session tools', () => { + beforeEach(() => { + resetMocks(); + mockVscode.debug.sessions = []; + mockVscode.debug.activeDebugSession = undefined; + }); + + it('lists sessions', async () => { + mockVscode.debug.sessions = [ + { id: '1', name: 'a', type: 'node' }, + { id: '2', name: 'b', type: 'python' }, + ]; + + const result = await listDebugSessions(); + + expect(result.sessions).toHaveLength(2); + expect(result.sessions[0].id).toBe('1'); + }); + + it('handles missing vscode.debug.sessions by returning empty list', async () => { + (mockVscode.debug as any).sessions = undefined; + + const result = await listDebugSessions(); + + expect(result.sessions).toEqual([]); + }); + + it('starts a debug session', async () => { + let startCb: ((s: any) => void) | undefined; + mockVscode.debug.onDidStartDebugSession.mockImplementation((cb: any) => { + startCb = cb; + return { dispose: vi.fn() } as any; + }); + mockVscode.debug.startDebugging.mockResolvedValue(true); + + const result = await startDebugSession( + startDebugSessionSchema.parse({ + configurationJson: JSON.stringify({ type: 'node', request: 'launch', name: 'test' }), + }) + ); + + // If the start event isn't observed, we still consider the start requested successfully. + expect(result.success).toBe(true); + expect(mockVscode.debug.startDebugging).toHaveBeenCalled(); + + // Simulate the event arriving shortly after (and ensure it doesn't break anything) + startCb?.({ id: '1', name: 'test', type: 'node' }); + }); + + it('stops all debug sessions', async () => { + mockVscode.debug.sessions = [{ id: '1', name: 'a', type: 'node' }]; + mockVscode.debug.stopDebugging.mockResolvedValue(undefined); + + const result = await stopDebugSession(stopDebugSessionSchema.parse({ stopAll: true })); + + expect(result.success).toBe(true); + expect(result.stopped).toBe(1); + expect(mockVscode.debug.stopDebugging).toHaveBeenCalled(); + }); + + it('restarts a debug session', async () => { + mockVscode.debug.sessions = [ + { + id: '1', + name: 'a', + type: 'node', + configuration: { type: 'node', request: 'launch', name: 'a' }, + workspaceFolder: mockVscode.workspace.workspaceFolders?.[0], + }, + ]; + mockVscode.debug.stopDebugging.mockResolvedValue(undefined); + mockVscode.debug.startDebugging.mockResolvedValue(true); + + const result = await restartDebugSession(restartDebugSessionSchema.parse({ sessionId: '1' })); + + expect(result.success).toBe(true); + expect(mockVscode.debug.stopDebugging).toHaveBeenCalled(); + expect(mockVscode.debug.startDebugging).toHaveBeenCalled(); + }); +}); diff --git a/src/tools/debugSessions.ts b/src/tools/debugSessions.ts new file mode 100644 index 0000000..cc50a86 --- /dev/null +++ b/src/tools/debugSessions.ts @@ -0,0 +1,199 @@ +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { getKnownDebugSessions, getKnownDebugSessionById, recordDebugSession } from '../utils/debugSessionRegistry'; + +export const listDebugSessionsSchema = z.object({}); + +function getSessions(): readonly vscode.DebugSession[] { + // Some VS Code builds/environments may not expose vscode.debug.sessions. + const apiSessions = (vscode.debug as unknown as { sessions?: readonly vscode.DebugSession[] }) + .sessions; + const activeSession = (vscode.debug as unknown as { activeDebugSession?: vscode.DebugSession }) + .activeDebugSession; + const knownSessions = getKnownDebugSessions(); + if (!apiSessions) { + const merged = new Map(); + for (const session of knownSessions) merged.set(session.id, session); + if (activeSession) merged.set(activeSession.id, activeSession); + return Array.from(merged.values()); + } + + const merged = new Map(); + for (const session of apiSessions) merged.set(session.id, session); + for (const session of knownSessions) merged.set(session.id, session); + if (activeSession) merged.set(activeSession.id, activeSession); + return Array.from(merged.values()); +} + +export async function listDebugSessions(): Promise<{ + sessions: Array<{ id: string; name: string; type: string }>; +}> { + const sessions = getSessions(); + return { + sessions: sessions.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + })), + }; +} + +export const startDebugSessionSchema = z.object({ + workspaceFolderUri: z + .string() + .optional() + .describe('Optional workspace folder URI (file://...). If omitted, uses active folder.'), + configurationJson: z + .string() + .describe('JSON-encoded debug configuration (same shape as launch.json entry)'), +}); + +export async function startDebugSession( + params: z.infer +): Promise<{ success: boolean; sessionId?: string; message?: string }> { + let configuration: vscode.DebugConfiguration; + try { + configuration = JSON.parse(params.configurationJson) as vscode.DebugConfiguration; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: `Failed to parse configurationJson: ${errorMessage}` }; + } + + const folder = params.workspaceFolderUri + ? vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(params.workspaceFolderUri)) + : vscode.workspace.workspaceFolders?.[0]; + + const waitForStartEvent = async (): Promise => { + const debugApi = vscode.debug as unknown as { + onDidStartDebugSession?: (cb: (s: vscode.DebugSession) => void) => vscode.Disposable; + }; + + if (!debugApi.onDidStartDebugSession) { + return undefined; + } + + const expectedType = typeof configuration.type === 'string' ? configuration.type : undefined; + const expectedName = typeof configuration.name === 'string' ? configuration.name : undefined; + + return await new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + disposable?.dispose(); + resolve(undefined); + }, 2000); + + const disposable = debugApi.onDidStartDebugSession?.((session) => { + const typeOk = expectedType ? session.type === expectedType : true; + const nameOk = expectedName ? session.name === expectedName : true; + if (!typeOk || !nameOk) { + return; + } + if (settled) return; + settled = true; + clearTimeout(timeout); + disposable?.dispose(); + resolve(session); + }); + }); + }; + + const startEventPromise = waitForStartEvent(); + const started = await vscode.debug.startDebugging(folder, configuration); + if (!started) { + return { success: false, message: 'Failed to start debug session' }; + } + + const startedSession = await startEventPromise; + if (startedSession) { + recordDebugSession(startedSession); + return { success: true, sessionId: startedSession.id, message: 'Debug session started' }; + } + + const activeSession = (vscode.debug as unknown as { activeDebugSession?: vscode.DebugSession }) + .activeDebugSession; + if (activeSession) { + recordDebugSession(activeSession); + return { + success: true, + sessionId: activeSession.id, + message: 'Debug session started (observed via activeDebugSession)', + }; + } + + return { + success: true, + message: + 'Debug session start was requested, but no session id could be observed (it may have terminated immediately or the environment does not expose debug sessions)', + }; +} + +export const stopDebugSessionSchema = z.object({ + sessionId: z.string().optional().describe('Stop a specific debug session by id'), + stopAll: z.boolean().optional().default(false).describe('Stop all debug sessions'), +}); + +export async function stopDebugSession( + params: z.infer +): Promise<{ success: boolean; stopped: number; message?: string }> { + const sessions = getSessions(); + const activeSession = (vscode.debug as unknown as { activeDebugSession?: vscode.DebugSession }) + .activeDebugSession; + + const toStop = params.stopAll + ? sessions.length > 0 + ? sessions + : activeSession + ? [activeSession] + : [] + : params.sessionId + ? (() => { + const inSessions = sessions.filter((s) => s.id === params.sessionId); + if (inSessions.length > 0) return inSessions; + const known = getKnownDebugSessionById(params.sessionId); + return known ? [known] : []; + })() + : []; + + if (toStop.length === 0) { + return { success: false, stopped: 0, message: 'No matching debug sessions' }; + } + + let stopped = 0; + for (const session of toStop) { + try { + await vscode.debug.stopDebugging(session); + stopped++; + } catch { + // ignore and continue + } + } + + return { success: stopped > 0, stopped, message: `Stopped ${stopped} session(s)` }; +} + +export const restartDebugSessionSchema = z.object({ + sessionId: z.string().describe('Debug session id to restart'), +}); + +export async function restartDebugSession( + params: z.infer +): Promise<{ success: boolean; message?: string }> { + const session = getSessions().find((s) => s.id === params.sessionId); + if (!session) { + return { success: false, message: 'Debug session not found' }; + } + + try { + await vscode.debug.stopDebugging(session); + } catch { + return { success: false, message: 'Failed to stop debug session' }; + } + + const folder = session.workspaceFolder ?? vscode.workspace.workspaceFolders?.[0]; + const started = await vscode.debug.startDebugging(folder, session.configuration); + return started + ? { success: true, message: 'Debug session restarted' } + : { success: false, message: 'Failed to restart debug session' }; +} diff --git a/src/tools/documentLinks.test.ts b/src/tools/documentLinks.test.ts index 9286e34..eb1a949 100644 --- a/src/tools/documentLinks.test.ts +++ b/src/tools/documentLinks.test.ts @@ -7,7 +7,15 @@ vi.mock('vscode', async () => { }); import { getDocumentLinks, documentLinksSchema } from './documentLinks'; -import { mockVscode, resetMocks, MockUri, MockRange, MockPosition, MockDocumentLink } from '../test/helpers/mockVscode'; +import { + mockVscode, + resetMocks, + MockUri, + MockRange, + MockPosition, + MockDocumentLink, + MockTextDocument, +} from '../test/helpers/mockVscode'; describe('Document Links Tool', () => { beforeEach(() => { @@ -43,11 +51,13 @@ describe('Document Links Tool', () => { ), ]; + mockVscode.commands.getCommands.mockResolvedValue(['vscode.executeDocumentLinkProvider']); mockVscode.commands.executeCommand.mockResolvedValue(mockLinks); const result = await getDocumentLinks({ uri: '/test/file.ts' }); expect(result.links).toHaveLength(2); + expect(result.provider).toBe('vscode'); expect(result.links[0].target).toContain('other-file.ts'); expect(result.links[0].tooltip).toBe('Go to file'); expect(result.links[1].target).toContain('example.com'); @@ -62,16 +72,19 @@ describe('Document Links Tool', () => { ), ]; + mockVscode.commands.getCommands.mockResolvedValue(['vscode.executeDocumentLinkProvider']); mockVscode.commands.executeCommand.mockResolvedValue(mockLinks); const result = await getDocumentLinks({ uri: '/test/file.ts' }); expect(result.links).toHaveLength(1); + expect(result.provider).toBe('vscode'); expect(result.links[0].target).toBeUndefined(); expect(result.links[0].tooltip).toBeUndefined(); }); it('should handle file:// URIs', async () => { + mockVscode.commands.getCommands.mockResolvedValue(['vscode.executeDocumentLinkProvider']); mockVscode.commands.executeCommand.mockResolvedValue([]); await getDocumentLinks({ uri: 'file:///test/file.ts' }); @@ -83,11 +96,34 @@ describe('Document Links Tool', () => { }); it('should return empty array when no links available', async () => { + mockVscode.commands.getCommands.mockResolvedValue(['vscode.executeDocumentLinkProvider']); mockVscode.commands.executeCommand.mockResolvedValue(null); const result = await getDocumentLinks({ uri: '/test/file.ts' }); expect(result.links).toEqual([]); + expect(result.provider).toBe('vscode'); + }); + + it('falls back to a simple parser when the VS Code provider command is unavailable', async () => { + mockVscode.commands.getCommands.mockResolvedValue([]); + mockVscode.workspace.openTextDocument.mockImplementation((uri: MockUri) => { + return Promise.resolve( + new MockTextDocument( + uri, + 'markdown', + 1, + 'See https://example.com and [readme](README.md).' + ) + ); + }); + + const result = await getDocumentLinks({ uri: '/test/file.md' }); + + expect(result.provider).toBe('fallback'); + expect(result.links.length).toBeGreaterThanOrEqual(2); + expect(result.links.some((l) => (l.target || '').includes('example.com'))).toBe(true); + expect(result.links.some((l) => (l.target || '').includes('README.md'))).toBe(true); }); }); }); diff --git a/src/tools/documentLinks.ts b/src/tools/documentLinks.ts index ad35beb..898dc40 100644 --- a/src/tools/documentLinks.ts +++ b/src/tools/documentLinks.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import path from 'path'; import { z } from 'zod'; import { rangeToJSON, Range, ensureDocumentOpen } from '../adapters/vscodeAdapter'; @@ -12,34 +13,158 @@ export interface DocumentLinkInfo { tooltip?: string; } -export async function getDocumentLinks( - params: z.infer -): Promise<{ links: DocumentLinkInfo[] }> { - // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); +const DOCUMENT_LINK_PROVIDER_COMMAND = 'vscode.executeDocumentLinkProvider'; + +async function hasDocumentLinkProviderCommand(): Promise { + try { + const commands = await vscode.commands.getCommands(true); + return commands.includes(DOCUMENT_LINK_PROVIDER_COMMAND); + } catch { + return false; } +} - // Ensure document is open - await ensureDocumentOpen(uri); +function normalizeTarget(rawTarget: string, documentFsPath: string): string { + const target = rawTarget.trim(); + if (!target) { + return target; + } + + if (target.startsWith('http://') || target.startsWith('https://') || target.startsWith('file://')) { + return target; + } - const links = await vscode.commands.executeCommand( - 'vscode.executeDocumentLinkProvider', - uri - ); + const match = target.match(/^([^#?]+)(.*)$/); + const base = match?.[1] ?? target; + const suffix = match?.[2] ?? ''; - if (!links || links.length === 0) { - return { links: [] }; + if (path.isAbsolute(base)) { + return vscode.Uri.file(base).toString() + suffix; } - const result: DocumentLinkInfo[] = links.map((link) => ({ - range: rangeToJSON(link.range), - target: link.target?.toString(), - tooltip: link.tooltip, - })); + const resolved = path.join(path.dirname(documentFsPath), base); + return vscode.Uri.file(resolved).toString() + suffix; +} + +function extractFallbackLinks(document: vscode.TextDocument): DocumentLinkInfo[] { + const text = document.getText(); + const links: DocumentLinkInfo[] = []; + const seen = new Set(); + + const pushLink = (start: number, end: number, rawTarget: string, tooltip?: string) => { + const key = `${start}:${end}:${rawTarget}`; + if (seen.has(key)) { + return; + } + seen.add(key); + const startPos = document.positionAt(start); + const endPos = document.positionAt(end); + const range = new vscode.Range(startPos as any, endPos as any); + links.push({ + range: rangeToJSON(range as any), + target: normalizeTarget(rawTarget, document.uri.fsPath), + tooltip, + }); + }; - return { links: result }; + // Markdown links: [text](target) + const mdLinkRe = /\[[^\]]+\]\(([^)\s]+)\)/g; + for (const match of text.matchAll(mdLinkRe)) { + const full = match[0]; + const target = match[1]; + const fullIndex = match.index ?? -1; + if (fullIndex < 0) { + continue; + } + const targetIndex = full.lastIndexOf(target); + if (targetIndex < 0) { + continue; + } + const start = fullIndex + targetIndex; + const end = start + target.length; + pushLink(start, end, target, 'Parsed Markdown link'); + } + + // URLs in text + const urlRe = /\bhttps?:\/\/[^\s<>()"']+/g; + for (const match of text.matchAll(urlRe)) { + const url = match[0]; + const start = match.index ?? -1; + if (start < 0) { + continue; + } + pushLink(start, start + url.length, url, 'Parsed URL'); + } + + return links; +} + +export async function getDocumentLinks( + params: z.infer +): Promise<{ + links: DocumentLinkInfo[]; + provider?: 'vscode' | 'fallback'; + providerAvailable?: boolean; + message?: string; +}> { + try { + // Handle file:// URIs, absolute paths, and workspace-relative paths + let uri: vscode.Uri; + if (params.uri.startsWith('file://')) { + uri = vscode.Uri.parse(params.uri); + } else if (path.isAbsolute(params.uri)) { + uri = vscode.Uri.file(params.uri); + } else { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const resolved = root ? path.join(root, params.uri) : path.resolve(params.uri); + uri = vscode.Uri.file(resolved); + } + + // Ensure document is open + const document = await ensureDocumentOpen(uri); + + const providerAvailable = await hasDocumentLinkProviderCommand(); + if (!providerAvailable) { + return { + links: extractFallbackLinks(document), + provider: 'fallback', + providerAvailable: false, + message: `command '${DOCUMENT_LINK_PROVIDER_COMMAND}' not found; used fallback parser`, + }; + } + + let links: vscode.DocumentLink[] | undefined | null; + try { + links = await vscode.commands.executeCommand( + DOCUMENT_LINK_PROVIDER_COMMAND, + uri + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.toLowerCase().includes('not found')) { + return { + links: extractFallbackLinks(document), + provider: 'fallback', + providerAvailable: false, + message: `command '${DOCUMENT_LINK_PROVIDER_COMMAND}' not found; used fallback parser`, + }; + } + throw error; + } + + if (!links || links.length === 0) { + return { links: [], provider: 'vscode', providerAvailable: true }; + } + + const result: DocumentLinkInfo[] = links.map((link) => ({ + range: rangeToJSON(link.range), + target: link.target?.toString(), + tooltip: link.tooltip, + })); + + return { links: result, provider: 'vscode', providerAvailable: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { links: [], provider: 'fallback', providerAvailable: false, message: errorMessage }; + } } diff --git a/src/tools/focusEditor.test.ts b/src/tools/focusEditor.test.ts new file mode 100644 index 0000000..71effbb --- /dev/null +++ b/src/tools/focusEditor.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { focusEditor, focusEditorSchema } from './focusEditor'; +import { mockVscode, resetMocks, MockUri } from '../test/helpers/mockVscode'; + +describe('focus_editor', () => { + beforeEach(() => { + resetMocks(); + }); + + it('opens a document and sets selection', async () => { + const revealRange = vi.fn(); + const editor: any = { selection: undefined, revealRange }; + mockVscode.window.showTextDocument.mockResolvedValue(editor); + + const result = await focusEditor( + focusEditorSchema.parse({ + uri: '/test/file.ts', + startLine: 1, + startCharacter: 2, + endLine: 3, + endCharacter: 4, + }) + ); + + expect(result.success).toBe(true); + expect(mockVscode.window.showTextDocument).toHaveBeenCalledWith(expect.any(MockUri), { + preserveFocus: false, + preview: false, + }); + expect(editor.selection).toBeTruthy(); + expect(revealRange).toHaveBeenCalled(); + }); +}); + diff --git a/src/tools/focusEditor.ts b/src/tools/focusEditor.ts new file mode 100644 index 0000000..77da638 --- /dev/null +++ b/src/tools/focusEditor.ts @@ -0,0 +1,47 @@ +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { ensureDocumentOpen } from '../adapters/vscodeAdapter'; + +export const focusEditorSchema = z.object({ + uri: z.string().describe('File URI (file://...) or absolute file path'), + startLine: z.number().describe('Start line (0-based)'), + startCharacter: z.number().describe('Start character (0-based)'), + endLine: z.number().optional().describe('End line (0-based)'), + endCharacter: z.number().optional().describe('End character (0-based)'), + preserveFocus: z + .boolean() + .optional() + .default(false) + .describe('Preserve focus in current editor'), +}); + +export async function focusEditor( + params: z.infer +): Promise<{ success: boolean; message?: string }> { + try { + const uri = params.uri.startsWith('file://') ? vscode.Uri.parse(params.uri) : vscode.Uri.file(params.uri); + await ensureDocumentOpen(uri); + + const editor = await vscode.window.showTextDocument(uri, { + preserveFocus: params.preserveFocus, + preview: false, + }); + + const endLine = params.endLine ?? params.startLine; + const endCharacter = params.endCharacter ?? params.startCharacter; + + const range = new vscode.Range( + new vscode.Position(params.startLine, params.startCharacter), + new vscode.Position(endLine, endCharacter) + ); + + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: errorMessage }; + } +} + diff --git a/src/tools/formatDocument.ts b/src/tools/formatDocument.ts index 9b56a8c..f45605d 100644 --- a/src/tools/formatDocument.ts +++ b/src/tools/formatDocument.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import { rangeToJSON, ensureDocumentOpen } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; export const formatDocumentSchema = z.object({ uri: z.string().describe('File URI or absolute file path'), @@ -22,14 +23,17 @@ export interface TextEdit { export async function formatDocument( params: z.infer -): Promise<{ success: boolean; edits?: TextEdit[]; message?: string }> { +): Promise<{ + success: boolean; + applied?: boolean; + saved?: boolean; + edits?: TextEdit[]; + message?: string; +}> { // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); - } + const uri = params.uri.startsWith('file://') + ? vscode.Uri.parse(params.uri) + : vscode.Uri.file(params.uri); // Ensure document is open const document = await ensureDocumentOpen(uri); @@ -59,6 +63,8 @@ export async function formatDocument( if (params.dryRun) { return { success: true, + applied: false, + saved: false, edits: textEdits, message: `Dry-run: ${edits.length} formatting change(s) would be applied`, }; @@ -70,16 +76,27 @@ export async function formatDocument( const applied = await vscode.workspace.applyEdit(workspaceEdit); - if (applied) { - return { - success: true, - edits: textEdits, - message: `Successfully formatted document with ${edits.length} change(s)`, - }; - } else { + if (!applied) { return { success: false, message: 'Failed to apply formatting changes', }; } + + // Optionally save + const config = getConfiguration(); + let saved = false; + if (config.autoSaveAfterToolEdits) { + saved = await document.save(); + } + + return { + success: true, + applied: true, + saved, + edits: textEdits, + message: `Successfully formatted document with ${edits.length} change(s)${ + config.autoSaveAfterToolEdits ? (saved ? ' (saved)' : ' (save failed)') : '' + }`, + }; } diff --git a/src/tools/formatRange.ts b/src/tools/formatRange.ts index 1b79b0d..87f62fe 100644 --- a/src/tools/formatRange.ts +++ b/src/tools/formatRange.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import { rangeToJSON, ensureDocumentOpen } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; export const formatRangeSchema = z.object({ uri: z.string().describe('File URI or absolute file path'), @@ -26,17 +27,20 @@ export interface TextEdit { export async function formatRange( params: z.infer -): Promise<{ success: boolean; edits?: TextEdit[]; message?: string }> { +): Promise<{ + success: boolean; + applied?: boolean; + saved?: boolean; + edits?: TextEdit[]; + message?: string; +}> { // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); - } + const uri = params.uri.startsWith('file://') + ? vscode.Uri.parse(params.uri) + : vscode.Uri.file(params.uri); // Ensure document is open - await ensureDocumentOpen(uri); + const document = await ensureDocumentOpen(uri); const range = new vscode.Range( new vscode.Position(params.startLine, params.startCharacter), @@ -69,6 +73,8 @@ export async function formatRange( if (params.dryRun) { return { success: true, + applied: false, + saved: false, edits: textEdits, message: `Dry-run: ${edits.length} formatting change(s) would be applied`, }; @@ -80,16 +86,26 @@ export async function formatRange( const applied = await vscode.workspace.applyEdit(workspaceEdit); - if (applied) { - return { - success: true, - edits: textEdits, - message: `Successfully formatted range with ${edits.length} change(s)`, - }; - } else { + if (!applied) { return { success: false, message: 'Failed to apply formatting changes', }; } + + const config = getConfiguration(); + let saved = false; + if (config.autoSaveAfterToolEdits) { + saved = await document.save(); + } + + return { + success: true, + applied: true, + saved, + edits: textEdits, + message: `Successfully formatted range with ${edits.length} change(s)${ + config.autoSaveAfterToolEdits ? (saved ? ' (saved)' : ' (save failed)') : '' + }`, + }; } diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts new file mode 100644 index 0000000..6ce9294 --- /dev/null +++ b/src/tools/index.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock vscode module - must be before importing tools index (it imports many vscode-dependent tools) +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { getAllTools } from './index'; + +describe('tools schema export', () => { + it('exports correct JSON schema types for defaulted non-string inputs', () => { + const tools = getAllTools(); + const listDirectory = tools.find((tool) => tool.name === 'list_directory'); + expect(listDirectory).toBeTruthy(); + + const properties = (listDirectory as any).inputSchema?.properties as Record; + expect(properties).toBeTruthy(); + + expect(properties.maxDepth?.type).toBe('number'); + expect(properties.includeFiles?.type).toBe('boolean'); + expect(properties.includeDirectories?.type).toBe('boolean'); + + const workspaceSymbols = tools.find((tool) => tool.name === 'workspace_symbols'); + expect(workspaceSymbols).toBeTruthy(); + const wsProps = (workspaceSymbols as any).inputSchema?.properties as Record; + expect(wsProps.maxResults?.type).toBe('number'); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index cb37cfe..30fc7f8 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,6 @@ import { z } from 'zod'; +import * as vscode from 'vscode'; +import { MCPServerConfig } from '../config/settings'; // Import all tool modules import { openFolder, getOpenFolders, openFolderSchema, getOpenFoldersSchema } from './workspace'; @@ -28,16 +30,38 @@ import { formatRange, formatRangeSchema } from './formatRange'; import { organizeImports, organizeImportsSchema } from './organizeImports'; import { renameSymbol, renameSymbolSchema } from './renameSymbol'; import { applyCodeAction, applyCodeActionSchema } from './applyCodeAction'; +import { previewUrl, previewUrlSchema } from './previewUrl'; +import { listDirectory, listDirectorySchema } from './listDirectory'; +import { focusEditor, focusEditorSchema } from './focusEditor'; +import { textEditor, textEditorSchema } from './textEditor'; +import { + executeCommand, + executeCommandSchema, + getTerminalOutput, + getTerminalOutputSchema, +} from './commandExecution'; +import { + listVSCodeCommands, + listVSCodeCommandsSchema, + executeVSCodeCommand, + executeVSCodeCommandSchema, +} from './vscodeCommands'; +import { + listDebugSessions, + listDebugSessionsSchema, + startDebugSession, + startDebugSessionSchema, + restartDebugSession, + restartDebugSessionSchema, + stopDebugSession, + stopDebugSessionSchema, +} from './debugSessions'; // Tool definition type for MCP interface ToolDefinition { name: string; description: string; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; + inputSchema: Record; } // Tool registry @@ -56,13 +80,17 @@ function zodToJsonSchema(schema: z.ZodObject): ToolDefinition['in const zodType = value as z.ZodTypeAny; const description = zodType.description; - // Handle optional types + // Handle optional / defaulted types let innerType = zodType; let isOptional = false; - if (zodType instanceof z.ZodOptional || zodType instanceof z.ZodDefault) { + while (innerType instanceof z.ZodOptional || innerType instanceof z.ZodDefault) { isOptional = true; - innerType = zodType instanceof z.ZodOptional ? zodType.unwrap() : zodType._def.innerType; + if (innerType instanceof z.ZodOptional) { + innerType = innerType.unwrap(); + } else { + innerType = innerType._def.innerType as z.ZodTypeAny; + } } // Convert zod type to JSON schema type @@ -100,6 +128,72 @@ function zodToJsonSchema(schema: z.ZodObject): ToolDefinition['in const tools: Map = new Map(); +const VSCODE_TOOLS_NAMESPACE = 'vscode'; +const VSCODE_TOOLS_REQUIRE_OBJECT_SCHEMA = true; +const DEFAULT_TOOLS_NAMESPACE = 'vsc'; + +function shouldExposeLanguageModelTool( + tool: vscode.LanguageModelToolInformation, + config: MCPServerConfig +): boolean { + if (!config.enableVSCodeTools) { + return false; + } + + const schema = tool.inputSchema as { type?: unknown } | undefined; + const schemaType = typeof schema?.type === 'string' ? schema.type : undefined; + + if (VSCODE_TOOLS_REQUIRE_OBJECT_SCHEMA && schemaType !== 'object') { + return false; + } + + return config.vscodeToolsAllowedNames.includes(tool.name); +} + +function getLanguageModelToolNamespace(config: MCPServerConfig): string { + void config; + return VSCODE_TOOLS_NAMESPACE; +} + +function getLanguageModelToolExposedName(toolName: string, config: MCPServerConfig): string { + return `${getLanguageModelToolNamespace(config)}.${toolName}`; +} + +function tryParseLanguageModelToolName( + exposedName: string, + config: MCPServerConfig +): string | undefined { + const ns = `${getLanguageModelToolNamespace(config)}.`; + if (!exposedName.startsWith(ns)) { + return undefined; + } + return exposedName.slice(ns.length); +} + +function getDefaultToolExposedName(toolName: string): string { + return `${DEFAULT_TOOLS_NAMESPACE}.${toolName}`; +} + +function tryParseDefaultToolName(exposedName: string): string | undefined { + const ns = `${DEFAULT_TOOLS_NAMESPACE}.`; + if (!exposedName.startsWith(ns)) { + return undefined; + } + return exposedName.slice(ns.length); +} + +function shouldExposeDefaultTool(toolName: string, config: MCPServerConfig): boolean { + if (config.enableDefaultTools === false) { + return false; + } + + if (!config.defaultToolsAllowedConfigured) { + return true; + } + + return config.defaultToolsAllowedNames.includes(toolName); +} + // Register all tools function registerTool( name: string, @@ -317,15 +411,188 @@ registerTool( applyCodeAction as (params: Record) => Promise ); +// Workflow/editor tools +registerTool( + 'execute_command', + 'Execute a shell command (unsafe; gated by configuration)', + executeCommandSchema, + executeCommand as (params: Record) => Promise +); + +registerTool( + 'get_terminal_output', + 'Get output for a previously started execute_command process id', + getTerminalOutputSchema, + getTerminalOutput as (params: Record) => Promise +); + +registerTool( + 'preview_url', + 'Open a URL in VS Code (Simple Browser) or externally', + previewUrlSchema, + previewUrl as (params: Record) => Promise +); + +registerTool( + 'text_editor', + 'Basic file operations: view, replace, insert, create, undo', + textEditorSchema, + textEditor as (params: Record) => Promise +); + +registerTool( + 'list_directory', + 'List directory contents as a (bounded) tree; supports depth/entry caps and excludes', + listDirectorySchema, + listDirectory as (params: Record) => Promise +); + +registerTool( + 'focus_editor', + 'Open a file and focus a specific range in the editor', + focusEditorSchema, + focusEditor as (params: Record) => Promise +); + +registerTool( + 'list_debug_sessions', + 'List active debug sessions', + listDebugSessionsSchema, + listDebugSessions as (params: Record) => Promise +); + +registerTool( + 'start_debug_session', + 'Start a new debug session from a JSON debug configuration', + startDebugSessionSchema, + startDebugSession as (params: Record) => Promise +); + +registerTool( + 'restart_debug_session', + 'Restart a running debug session by id', + restartDebugSessionSchema, + restartDebugSession as (params: Record) => Promise +); + +registerTool( + 'stop_debug_session', + 'Stop a debug session by id or stop all sessions', + stopDebugSessionSchema, + stopDebugSession as (params: Record) => Promise +); + +registerTool( + 'list_vscode_commands', + 'List available VS Code command ids', + listVSCodeCommandsSchema, + listVSCodeCommands as (params: Record) => Promise +); + +registerTool( + 'execute_vscode_command', + 'Execute a VS Code command (unsafe; gated by configuration)', + executeVSCodeCommandSchema, + executeVSCodeCommand as (params: Record) => Promise +); + // Export functions -export function getAllTools(): ToolDefinition[] { - return Array.from(tools.values()).map((entry) => entry.definition); +export function getAllTools(config?: MCPServerConfig): ToolDefinition[] { + const builtInTools = Array.from(tools.values()).map((entry) => entry.definition); + + if (!config) { + return builtInTools; + } + + const enabledLocalTools = builtInTools + .filter((tool) => shouldExposeDefaultTool(tool.name, config)) + .map((tool) => ({ + ...tool, + name: getDefaultToolExposedName(tool.name), + })); + + if (!config.enableVSCodeTools) { + return enabledLocalTools; + } + + const lmTools = vscode.lm?.tools ? Array.from(vscode.lm.tools) : []; + const exposedLmTools: ToolDefinition[] = []; + + for (const tool of lmTools) { + if (!shouldExposeLanguageModelTool(tool, config)) { + continue; + } + + const inputSchema = + tool.inputSchema && typeof tool.inputSchema === 'object' + ? (tool.inputSchema as Record) + : { type: 'object', properties: {} }; + + exposedLmTools.push({ + name: getLanguageModelToolExposedName(tool.name, config), + description: tool.description || `vscode.lm tool: ${tool.name}`, + inputSchema, + }); + } + + return [...enabledLocalTools, ...exposedLmTools]; } export async function callTool( name: string, - params: Record + params: Record, + config?: MCPServerConfig ): Promise { + if (config) { + const defaultName = tryParseDefaultToolName(name); + if (defaultName) { + if (!shouldExposeDefaultTool(defaultName, config)) { + throw new Error(`Built-in tool is not exposed: ${defaultName}`); + } + + const entry = tools.get(defaultName); + if (!entry) { + throw new Error(`Unknown tool: ${defaultName}`); + } + + const validatedParams = entry.schema.parse(params); + return entry.handler(validatedParams); + } + + const lmName = tryParseLanguageModelToolName(name, config); + if (lmName) { + if (!vscode.lm?.tools) { + throw new Error('vscode.lm.tools is not available'); + } + + const lmTool = Array.from(vscode.lm.tools).find((t) => t.name === lmName); + if (!lmTool) { + throw new Error(`Unknown vscode.lm tool: ${lmName}`); + } + + if (!shouldExposeLanguageModelTool(lmTool, config)) { + throw new Error(`vscode.lm tool is not exposed: ${lmName}`); + } + + const result = await vscode.lm.invokeTool(lmName, { + input: params, + toolInvocationToken: undefined, + }); + + return result; + } + + // Backward compatibility: allow legacy un-namespaced built-in tool names, + // but still enforce the allowlist when configured. + if (tools.has(name) && !shouldExposeDefaultTool(name, config)) { + throw new Error(`Built-in tool is not exposed: ${name}`); + } + } + + if (config && config.enableDefaultTools === false) { + throw new Error('This MCP server is configured to not expose built-in tools.'); + } + const entry = tools.get(name); if (!entry) { @@ -338,3 +605,9 @@ export async function callTool( // Call the handler return entry.handler(validatedParams); } + +export function getBuiltInToolsCatalog(): { name: string; description: string }[] { + return Array.from(tools.values()) + .map((entry) => ({ name: entry.definition.name, description: entry.definition.description })) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/tools/listDirectory.test.ts b/src/tools/listDirectory.test.ts new file mode 100644 index 0000000..ab1838c --- /dev/null +++ b/src/tools/listDirectory.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, writeFile, mkdir } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { listDirectory, listDirectorySchema } from './listDirectory'; + +describe('list_directory', () => { + it('returns a directory tree', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'mcp-listdir-')); + await mkdir(path.join(root, 'sub')); + await writeFile(path.join(root, 'a.txt'), 'hello', 'utf8'); + await writeFile(path.join(root, 'sub', 'b.txt'), 'world', 'utf8'); + + const result = await listDirectory( + listDirectorySchema.parse({ directoryPath: root, maxDepth: 2 }) + ); + + expect(result.success).toBe(true); + expect(result.truncated).toBe(false); + expect(result.tree?.type).toBe('directory'); + const children = (result.tree as any).children as any[]; + expect(children.some((c) => c.name === 'a.txt')).toBe(true); + expect(children.some((c) => c.name === 'sub')).toBe(true); + }); + + it('truncates output when maxEntries is exceeded', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'mcp-listdir-')); + for (let i = 0; i < 30; i++) { + await writeFile(path.join(root, `f-${i}.txt`), 'x', 'utf8'); + } + + const result = await listDirectory( + listDirectorySchema.parse({ directoryPath: root, maxDepth: 1, maxEntries: 5 }) + ); + + expect(result.success).toBe(true); + expect(result.truncated).toBe(true); + const children = (result.tree as any).children as any[]; + expect(children.length).toBeLessThanOrEqual(5); + }); +}); diff --git a/src/tools/listDirectory.ts b/src/tools/listDirectory.ts new file mode 100644 index 0000000..5050278 --- /dev/null +++ b/src/tools/listDirectory.ts @@ -0,0 +1,183 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { z } from 'zod'; + +export const listDirectorySchema = z.object({ + directoryPath: z.string().describe('Absolute path to a directory'), + maxDepth: z.number().optional().default(2).describe('Maximum recursion depth'), + includeFiles: z.boolean().optional().default(true).describe('Include files in output'), + includeDirectories: z + .boolean() + .optional() + .default(true) + .describe('Include directories in output'), + excludeHidden: z + .boolean() + .optional() + .default(true) + .describe('Exclude dotfiles / dot-directories (names starting with .)'), + excludeGlobs: z + .array(z.string()) + .optional() + .default(['**/node_modules/**', '**/.git/**', '**/.venv/**']) + .describe('Glob patterns to exclude (matched against path relative to directoryPath)'), + maxEntries: z + .number() + .optional() + .default(200) + .describe('Maximum number of nodes returned (prevents huge responses)'), +}); + +export type DirectoryTreeNode = + | { type: 'file'; name: string; path: string } + | { type: 'directory'; name: string; path: string; children: DirectoryTreeNode[] }; + +type TraversalBudget = { + remaining: number; + truncated: boolean; +}; + +function toPosixPath(p: string): string { + return p.split(path.sep).join('/'); +} + +function globToRegExp(glob: string): RegExp { + // Minimal glob support for "**", "*", and "?" matched against posix paths. + // This avoids adding extra deps (e.g. minimatch) while still being useful for common excludes. + const segments = toPosixPath(glob).split('/'); + let regex = '^'; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isLast = i === segments.length - 1; + + if (segment === '**') { + if (isLast) { + regex += '.*'; + } else { + // Zero or more path segments, each ending with '/' + regex += '(?:[^/]+/)*'; + } + continue; + } + + let part = segment.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + part = part.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]'); + regex += part; + + if (!isLast) { + regex += '/'; + } + } + + regex += '$'; + return new RegExp(regex); +} + +function isExcluded(relativePosixPath: string, excludeMatchers: RegExp[]): boolean { + // Also try matching without a leading "./" if present. + const rel = relativePosixPath.startsWith('./') ? relativePosixPath.slice(2) : relativePosixPath; + const relWithSlash = rel.endsWith('/') ? rel : `${rel}/`; + return excludeMatchers.some((re) => re.test(rel) || re.test(relWithSlash)); +} + +async function buildTree( + absolutePath: string, + depth: number, + maxDepth: number, + includeFiles: boolean, + includeDirectories: boolean, + rootAbsolutePath: string, + excludeHidden: boolean, + excludeMatchers: RegExp[], + budget: TraversalBudget +): Promise { + const stat = await fs.stat(absolutePath); + const name = path.basename(absolutePath); + + if (!stat.isDirectory()) { + return { type: 'file', name, path: absolutePath }; + } + + const children: DirectoryTreeNode[] = []; + if (depth < maxDepth) { + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (budget.remaining <= 0) { + budget.truncated = true; + break; + } + + if (excludeHidden && entry.name.startsWith('.')) { + continue; + } + + const childPath = path.join(absolutePath, entry.name); + const relativePosixPath = toPosixPath(path.relative(rootAbsolutePath, childPath)); + if (isExcluded(relativePosixPath, excludeMatchers)) { + continue; + } + + if (entry.isDirectory()) { + if (!includeDirectories) { + continue; + } + budget.remaining -= 1; + children.push( + await buildTree( + childPath, + depth + 1, + maxDepth, + includeFiles, + includeDirectories, + rootAbsolutePath, + excludeHidden, + excludeMatchers, + budget + ) + ); + } else if (entry.isFile()) { + if (!includeFiles) { + continue; + } + budget.remaining -= 1; + children.push({ type: 'file', name: entry.name, path: childPath }); + } + } + } + + return { type: 'directory', name, path: absolutePath, children }; +} + +export async function listDirectory( + params: z.infer +): Promise<{ + success: boolean; + tree?: DirectoryTreeNode; + truncated?: boolean; + message?: string; +}> { + try { + const absolutePath = path.resolve(params.directoryPath); + const excludeMatchers = (params.excludeGlobs || []).map(globToRegExp); + const budget: TraversalBudget = { remaining: Math.max(0, params.maxEntries), truncated: false }; + const tree = await buildTree( + absolutePath, + 0, + params.maxDepth, + params.includeFiles, + params.includeDirectories, + absolutePath, + params.excludeHidden, + excludeMatchers, + budget + ); + return { success: true, tree, truncated: budget.truncated }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: errorMessage }; + } +} + diff --git a/src/tools/organizeImports.ts b/src/tools/organizeImports.ts index cc64a3a..16ba414 100644 --- a/src/tools/organizeImports.ts +++ b/src/tools/organizeImports.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import { rangeToJSON, ensureDocumentOpen } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; +import { saveUris } from '../utils/autoSave'; export const organizeImportsSchema = z.object({ uri: z.string().describe('File URI or absolute file path'), @@ -22,14 +24,17 @@ export interface TextEdit { export async function organizeImports( params: z.infer -): Promise<{ success: boolean; edits?: TextEdit[]; message?: string }> { +): Promise<{ + success: boolean; + applied?: boolean; + saved?: boolean; + edits?: TextEdit[]; + message?: string; +}> { // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); - } + const uri = params.uri.startsWith('file://') + ? vscode.Uri.parse(params.uri) + : vscode.Uri.file(params.uri); // Ensure document is open await ensureDocumentOpen(uri); @@ -43,7 +48,13 @@ export async function organizeImports( const codeActions = await vscode.commands.executeCommand< (vscode.Command | vscode.CodeAction)[] - >('vscode.executeCodeActionProvider', uri, fullRange, vscode.CodeActionKind.SourceOrganizeImports); + >( + 'vscode.executeCodeActionProvider', + uri, + fullRange, + vscode.CodeActionKind.SourceOrganizeImports.value, + 1 + ); if (!codeActions || codeActions.length === 0) { return { @@ -94,6 +105,8 @@ export async function organizeImports( if (params.dryRun) { return { success: true, + applied: false, + saved: false, edits: allEdits, message: `Dry-run: ${allEdits.length} import change(s) would be applied`, }; @@ -102,16 +115,27 @@ export async function organizeImports( // Apply the edits const applied = await vscode.workspace.applyEdit(workspaceEdit); - if (applied) { - return { - success: true, - edits: allEdits, - message: `Successfully organized imports with ${allEdits.length} change(s)`, - }; - } else { + if (!applied) { return { success: false, message: 'Failed to apply import changes', }; } + + const config = getConfiguration(); + let saved = false; + if (config.autoSaveAfterToolEdits) { + const result = await saveUris(entries.map(([u]) => u)); + saved = result.failedUris.length === 0; + } + + return { + success: true, + applied: true, + saved, + edits: allEdits, + message: `Successfully organized imports with ${allEdits.length} change(s)${ + config.autoSaveAfterToolEdits ? (saved ? ' (saved)' : ' (save failed)') : '' + }`, + }; } diff --git a/src/tools/previewUrl.test.ts b/src/tools/previewUrl.test.ts new file mode 100644 index 0000000..728f4a1 --- /dev/null +++ b/src/tools/previewUrl.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { previewUrl, previewUrlSchema } from './previewUrl'; +import { mockVscode, resetMocks } from '../test/helpers/mockVscode'; + +describe('preview_url', () => { + beforeEach(() => { + resetMocks(); + }); + + it('opens via Simple Browser when available', async () => { + mockVscode.commands.executeCommand.mockResolvedValue(undefined); + + const result = await previewUrl(previewUrlSchema.parse({ url: 'https://example.com' })); + + expect(result.success).toBe(true); + expect(mockVscode.commands.executeCommand).toHaveBeenCalledWith( + 'simpleBrowser.show', + 'https://example.com' + ); + expect(mockVscode.env.openExternal).not.toHaveBeenCalled(); + }); + + it('falls back to external browser when Simple Browser fails', async () => { + mockVscode.commands.executeCommand.mockRejectedValue(new Error('no simpleBrowser')); + + const result = await previewUrl(previewUrlSchema.parse({ url: 'https://example.com' })); + + expect(result.success).toBe(true); + expect(mockVscode.env.openExternal).toHaveBeenCalled(); + }); +}); + diff --git a/src/tools/previewUrl.ts b/src/tools/previewUrl.ts new file mode 100644 index 0000000..ed7c4df --- /dev/null +++ b/src/tools/previewUrl.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; +import { z } from 'zod'; + +export const previewUrlSchema = z.object({ + url: z.string().describe('URL to open'), +}); + +export async function previewUrl( + params: z.infer +): Promise<{ success: boolean; message?: string }> { + try { + // Prefer VS Code's simple browser, then fall back to external browser. + try { + await vscode.commands.executeCommand('simpleBrowser.show', params.url); + return { success: true, message: 'Opened URL in VS Code Simple Browser' }; + } catch { + await vscode.env.openExternal(vscode.Uri.parse(params.url)); + return { success: true, message: 'Opened URL externally' }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: errorMessage }; + } +} + diff --git a/src/tools/renameSymbol.ts b/src/tools/renameSymbol.ts index 10e4afb..6328f05 100644 --- a/src/tools/renameSymbol.ts +++ b/src/tools/renameSymbol.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import { z } from 'zod'; import { rangeToJSON, ensureDocumentOpen } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; +import { saveUris } from '../utils/autoSave'; export const renameSymbolSchema = z.object({ uri: z.string().describe('File URI or absolute file path'), @@ -28,14 +30,17 @@ export interface FileEdit { export async function renameSymbol( params: z.infer -): Promise<{ success: boolean; changes?: FileEdit[]; message?: string }> { +): Promise<{ + success: boolean; + applied?: boolean; + saved?: boolean; + changes?: FileEdit[]; + message?: string; +}> { // Handle both file:// URIs and plain paths - let uri: vscode.Uri; - if (params.uri.startsWith('file://')) { - uri = vscode.Uri.parse(params.uri); - } else { - uri = vscode.Uri.file(params.uri); - } + const uri = params.uri.startsWith('file://') + ? vscode.Uri.parse(params.uri) + : vscode.Uri.file(params.uri); // Ensure document is open await ensureDocumentOpen(uri); @@ -89,6 +94,8 @@ export async function renameSymbol( if (params.dryRun) { return { success: true, + applied: false, + saved: false, changes: fileEdits, message: `Dry-run: Would rename symbol in ${fileEdits.length} file(s) with ${totalEdits} change(s)`, }; @@ -97,16 +104,27 @@ export async function renameSymbol( // Apply the edits const applied = await vscode.workspace.applyEdit(workspaceEdit); - if (applied) { - return { - success: true, - changes: fileEdits, - message: `Successfully renamed symbol in ${fileEdits.length} file(s) with ${totalEdits} change(s)`, - }; - } else { + if (!applied) { return { success: false, message: 'Failed to apply rename changes', }; } + + const config = getConfiguration(); + let saved = false; + if (config.autoSaveAfterToolEdits) { + const result = await saveUris(entries.map(([u]) => u)); + saved = result.failedUris.length === 0; + } + + return { + success: true, + applied: true, + saved, + changes: fileEdits, + message: `Successfully renamed symbol in ${fileEdits.length} file(s) with ${totalEdits} change(s)${ + config.autoSaveAfterToolEdits ? (saved ? ' (saved)' : ' (save failed)') : '' + }`, + }; } diff --git a/src/tools/textEditor.test.ts b/src/tools/textEditor.test.ts new file mode 100644 index 0000000..f089ebe --- /dev/null +++ b/src/tools/textEditor.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { textEditor, textEditorSchema } from './textEditor'; +import { mockVscode, resetMocks, MockTextDocument, MockUri } from '../test/helpers/mockVscode'; + +describe('text_editor', () => { + beforeEach(() => { + resetMocks(); + }); + + it('views file content', async () => { + mockVscode.workspace.openTextDocument.mockResolvedValue( + new MockTextDocument(MockUri.file('/test/a.txt'), 'text', 1, 'hello') + ); + + const result = await textEditor( + textEditorSchema.parse({ action: 'view', uri: '/test/a.txt' }) + ); + + expect(result.success).toBe(true); + expect(result.content).toBe('hello'); + }); + + it('creates a file', async () => { + const result = await textEditor( + textEditorSchema.parse({ + action: 'create', + uri: '/test/new.txt', + content: 'data', + }) + ); + + expect(result.success).toBe(true); + expect(mockVscode.workspace.fs.writeFile).toHaveBeenCalled(); + }); + + it('previews file creation in dry-run mode', async () => { + const result = await textEditor( + textEditorSchema.parse({ + action: 'create', + uri: '/test/new.txt', + content: 'data', + dryRun: true, + }) + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('Dry-run'); + expect(mockVscode.workspace.fs.writeFile).not.toHaveBeenCalled(); + }); + + it('replaces text via workspace edit', async () => { + mockVscode.workspace.openTextDocument.mockResolvedValue( + new MockTextDocument(MockUri.file('/test/a.txt'), 'text', 1, 'hello') + ); + mockVscode.workspace.applyEdit.mockResolvedValue(true); + + const result = await textEditor( + textEditorSchema.parse({ + action: 'replace', + uri: '/test/a.txt', + startLine: 0, + startCharacter: 0, + endLine: 0, + endCharacter: 5, + text: 'bye', + }) + ); + + expect(result.success).toBe(true); + expect(mockVscode.workspace.applyEdit).toHaveBeenCalled(); + }); + + it('previews replace in dry-run mode without applying', async () => { + mockVscode.workspace.openTextDocument.mockResolvedValue( + new MockTextDocument(MockUri.file('/test/a.txt'), 'text', 1, 'hello') + ); + + const result = await textEditor( + textEditorSchema.parse({ + action: 'replace', + uri: '/test/a.txt', + startLine: 0, + startCharacter: 0, + endLine: 0, + endCharacter: 5, + text: 'bye', + dryRun: true, + }) + ); + + expect(result.success).toBe(true); + expect(result.message).toContain('Dry-run'); + expect(mockVscode.workspace.applyEdit).not.toHaveBeenCalled(); + }); + + it('runs undo', async () => { + mockVscode.commands.executeCommand.mockResolvedValue(undefined); + + const result = await textEditor(textEditorSchema.parse({ action: 'undo' })); + + expect(result.success).toBe(true); + expect(mockVscode.commands.executeCommand).toHaveBeenCalledWith('undo'); + }); + + it('previews undo in dry-run mode without executing', async () => { + const result = await textEditor(textEditorSchema.parse({ action: 'undo', dryRun: true })); + + expect(result.success).toBe(true); + expect(result.message).toContain('Dry-run'); + expect(mockVscode.commands.executeCommand).not.toHaveBeenCalled(); + }); +}); + diff --git a/src/tools/textEditor.ts b/src/tools/textEditor.ts new file mode 100644 index 0000000..e28497b --- /dev/null +++ b/src/tools/textEditor.ts @@ -0,0 +1,149 @@ +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { ensureDocumentOpen, rangeToJSON } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; + +export const textEditorSchema = z.object({ + action: z + .enum(['view', 'replace', 'insert', 'create', 'undo']) + .describe('Action to perform'), + uri: z.string().optional().describe('File URI (file://...) or absolute file path'), + content: z.string().optional().describe('File content for create'), + startLine: z.number().optional().describe('Start line (0-based)'), + startCharacter: z.number().optional().describe('Start character (0-based)'), + endLine: z.number().optional().describe('End line (0-based)'), + endCharacter: z.number().optional().describe('End character (0-based)'), + text: z.string().optional().describe('Replacement/insert text'), + dryRun: z + .boolean() + .optional() + .describe('Preview changes without applying them (default: false)'), +}); + +function toUri(uriOrPath: string): vscode.Uri { + return uriOrPath.startsWith('file://') ? vscode.Uri.parse(uriOrPath) : vscode.Uri.file(uriOrPath); +} + +function requireField(value: T | undefined, name: string): T { + if (value === undefined) { + throw new Error(`Missing required field: ${name}`); + } + return value; +} + +export interface TextEdit { + range: { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; + }; + newText: string; +} + +export async function textEditor( + params: z.infer +): Promise<{ + success: boolean; + applied?: boolean; + message?: string; + uri?: string; + content?: string; + edits?: TextEdit[]; + saved?: boolean; +}> { + try { + if (params.action === 'undo') { + if (params.dryRun) { + return { success: true, applied: false, saved: false, message: 'Dry-run: Would execute undo' }; + } + await vscode.commands.executeCommand('undo'); + return { success: true, applied: true, saved: false, message: 'Undo executed' }; + } + + const uriParam = requireField(params.uri, 'uri'); + const uri = toUri(uriParam); + + if (params.action === 'create') { + const content = requireField(params.content, 'content'); + if (params.dryRun) { + return { + success: true, + applied: false, + saved: false, + message: 'Dry-run: File would be created', + uri: uri.toString(), + content, + }; + } + const bytes = new TextEncoder().encode(content); + await vscode.workspace.fs.writeFile(uri, bytes); + return { success: true, applied: true, saved: true, message: 'File created', uri: uri.toString() }; + } + + const document = await ensureDocumentOpen(uri); + + if (params.action === 'view') { + return { + success: true, + applied: false, + saved: false, + uri: uri.toString(), + content: document.getText(), + }; + } + + const startLine = requireField(params.startLine, 'startLine'); + const startCharacter = requireField(params.startCharacter, 'startCharacter'); + + const endLine = params.endLine ?? startLine; + const endCharacter = params.endCharacter ?? startCharacter; + const text = requireField(params.text, 'text'); + + const range = new vscode.Range( + new vscode.Position(startLine, startCharacter), + new vscode.Position(endLine, endCharacter) + ); + + const edits: TextEdit[] = [{ range: rangeToJSON(range), newText: text }]; + + if (params.dryRun) { + const verb = params.action === 'insert' ? 'insert' : 'replace'; + return { + success: true, + applied: false, + saved: false, + edits, + message: `Dry-run: Would ${verb} text`, + }; + } + + const edit = vscode.TextEdit.replace(range, text); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(uri, [edit]); + + const applied = await vscode.workspace.applyEdit(workspaceEdit); + if (!applied) { + return { success: false, message: 'Failed to apply edit' }; + } + + const config = getConfiguration(); + const saved = config.autoSaveAfterToolEdits ? await document.save() : false; + + const verb = params.action === 'insert' ? 'Text inserted' : 'Text replaced'; + return { + success: true, + applied: true, + edits, + saved, + message: config.autoSaveAfterToolEdits + ? saved + ? `${verb} (saved)` + : `${verb} (save failed)` + : `${verb} (not saved)`, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: errorMessage }; + } +} diff --git a/src/tools/vscodeCommands.test.ts b/src/tools/vscodeCommands.test.ts new file mode 100644 index 0000000..3659898 --- /dev/null +++ b/src/tools/vscodeCommands.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', async () => { + const { mockVscode } = await import('../test/helpers/mockVscode'); + return mockVscode; +}); + +import { + listVSCodeCommands, + listVSCodeCommandsSchema, + executeVSCodeCommand, + executeVSCodeCommandSchema, +} from './vscodeCommands'; +import { mockVscode, resetMocks } from '../test/helpers/mockVscode'; + +describe('VS Code command tools', () => { + beforeEach(() => { + resetMocks(); + }); + + it('lists commands', async () => { + mockVscode.commands.getCommands.mockResolvedValue(['a', 'b']); + + const result = await listVSCodeCommands(listVSCodeCommandsSchema.parse({})); + + expect(result.commands).toEqual(['a', 'b']); + expect(mockVscode.commands.getCommands).toHaveBeenCalledWith(false); + }); + + it('blocks execute_vscode_command when unsafe tools disabled', async () => { + (mockVscode.workspace.getConfiguration as any).mockReturnValue({ + get: (key: string, defaultValue: any) => { + if (key === 'enableUnsafeTools') return false; + return defaultValue; + }, + update: vi.fn(), + }); + + const result = await executeVSCodeCommand( + executeVSCodeCommandSchema.parse({ command: 'workbench.action.files.newUntitledFile' }) + ); + + expect(result.success).toBe(false); + expect(mockVscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it('executes command when unsafe tools enabled', async () => { + (mockVscode.workspace.getConfiguration as any).mockReturnValue({ + get: (key: string, defaultValue: any) => { + if (key === 'enableUnsafeTools') return true; + return defaultValue; + }, + update: vi.fn(), + }); + + mockVscode.commands.executeCommand.mockResolvedValue('ok'); + + const result = await executeVSCodeCommand( + executeVSCodeCommandSchema.parse({ + command: 'some.command', + argsJson: '[1,"two"]', + }) + ); + + expect(result.success).toBe(true); + expect(result.result).toBe('ok'); + expect(mockVscode.commands.executeCommand).toHaveBeenCalledWith('some.command', 1, 'two'); + }); +}); + diff --git a/src/tools/vscodeCommands.ts b/src/tools/vscodeCommands.ts new file mode 100644 index 0000000..e52e02d --- /dev/null +++ b/src/tools/vscodeCommands.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { getConfiguration } from '../config/settings'; + +export const listVSCodeCommandsSchema = z.object({ + includeInternal: z + .boolean() + .optional() + .default(false) + .describe('Include internal commands (may be a large list)'), +}); + +export async function listVSCodeCommands( + params: z.infer +): Promise<{ commands: string[] }> { + const commands = await vscode.commands.getCommands(params.includeInternal); + return { commands }; +} + +export const executeVSCodeCommandSchema = z.object({ + command: z.string().describe('VS Code command id'), + argsJson: z + .string() + .optional() + .describe('JSON-encoded array of arguments to pass to the command'), +}); + +export async function executeVSCodeCommand( + params: z.infer +): Promise<{ success: boolean; result?: unknown; message?: string }> { + const config = getConfiguration(); + if (!config.enableUnsafeTools) { + return { + success: false, + message: + 'Unsafe tools are disabled. Enable codingwithcalvin.mcp.enableUnsafeTools to use execute_vscode_command.', + }; + } + + let args: unknown[] = []; + if (params.argsJson) { + try { + const parsed = JSON.parse(params.argsJson); + if (!Array.isArray(parsed)) { + return { success: false, message: 'argsJson must be a JSON array' }; + } + args = parsed; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, message: `Failed to parse argsJson: ${errorMessage}` }; + } + } + + const result = await vscode.commands.executeCommand(params.command, ...args); + return { success: true, result }; +} + diff --git a/src/tools/workspaceTextSearch.ts b/src/tools/workspaceTextSearch.ts index 5fe7a67..bcc45a2 100644 --- a/src/tools/workspaceTextSearch.ts +++ b/src/tools/workspaceTextSearch.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { z } from 'zod'; -import { rangeToJSON, Range } from '../adapters/vscodeAdapter'; +import { Range, rangeToJSON } from '../adapters/vscodeAdapter'; +import { getConfiguration } from '../config/settings'; export const workspaceTextSearchSchema = z.object({ query: z.string().describe('Text or regex pattern to search for'), @@ -33,18 +34,48 @@ export interface TextSearchResult { }[]; } +type WorkspaceTextSearchResponse = { + results: TextSearchResult[]; + backend: 'findTextInFiles' | 'fallback'; + warning?: string; +}; + export async function searchWorkspaceText( params: z.infer -): Promise<{ results: TextSearchResult[] }> { +): Promise { const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - return { results: [] }; + return { results: [], backend: 'fallback' }; + } + + const config = getConfiguration(); + + // Prefer findTextInFiles when enabled (fast), but gracefully fall back if unavailable. + if (config.useFindTextInFiles && typeof vscode.workspace.findTextInFiles === 'function') { + try { + return await searchWithFindTextInFiles(params); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // If this API is blocked (proposed API) or otherwise unavailable, fall back. + return await searchWithFallback(params, `findTextInFiles unavailable: ${message}`); + } } - // Build the search query + return await searchWithFallback( + params, + config.useFindTextInFiles + ? 'findTextInFiles not available in this VS Code build/session; using fallback search.' + : 'useFindTextInFiles is disabled; using fallback search.' + ); +} + +async function searchWithFindTextInFiles( + params: z.infer +): Promise { + const maxResults = params.maxResults ?? 1000; + const searchOptions: vscode.FindTextInFilesOptions = { - maxResults: params.maxResults || 1000, + maxResults, include: params.includePattern, exclude: params.excludePattern, }; @@ -56,7 +87,6 @@ export async function searchWorkspaceText( pattern = new RegExp(params.query, flags); } - // Perform the search const results: Map = new Map(); await vscode.workspace.findTextInFiles( @@ -79,7 +109,6 @@ export async function searchWorkspaceText( const fileResult = results.get(uri)!; - // Add matches from this result if ('ranges' in result) { // TextSearchMatch type for (const range of result.ranges) { @@ -93,5 +122,130 @@ export async function searchWorkspaceText( } ); - return { results: Array.from(results.values()) }; + return { results: Array.from(results.values()), backend: 'findTextInFiles' }; +} + +async function searchWithFallback( + params: z.infer, + warning?: string +): Promise { + const maxResults = params.maxResults ?? 1000; + const isCaseSensitive = params.isCaseSensitive ?? false; + const isRegex = params.isRegex ?? false; + + // Find candidate files + const include = params.includePattern ?? '**/*'; + const exclude = params.excludePattern; + + const files = await vscode.workspace.findFiles(include, exclude, Math.max(1000, maxResults)); + + const results: Map = new Map(); + let totalMatches = 0; + + const textDecoder = new TextDecoder('utf-8'); + + const regex = isRegex + ? new RegExp(params.query, `${isCaseSensitive ? '' : 'i'}g`) + : undefined; + const needle = isRegex + ? undefined + : isCaseSensitive + ? params.query + : params.query.toLowerCase(); + + for (const uri of files) { + if (totalMatches >= maxResults) break; + + let text: string; + try { + const bytes = await vscode.workspace.fs.readFile(uri); + text = textDecoder.decode(bytes); + } catch { + // Ignore unreadable/binary files + continue; + } + + const lines = text.split(/\r?\n/); + + for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { + if (totalMatches >= maxResults) break; + + const line = lines[lineNumber]; + + if (regex) { + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(line)) !== null) { + const start = match.index; + const length = match[0]?.length ?? 0; + const end = start + length; + + pushMatch(results, uri, lineNumber, start, end, line); + totalMatches++; + + if (totalMatches >= maxResults) break; + + // Avoid infinite loops on zero-length matches + if (length === 0) { + regex.lastIndex++; + } + } + } else if (needle) { + const haystack = isCaseSensitive ? line : line.toLowerCase(); + let idx = 0; + while (true) { + const found = haystack.indexOf(needle, idx); + if (found === -1) break; + + const start = found; + const end = found + needle.length; + + pushMatch(results, uri, lineNumber, start, end, line); + totalMatches++; + + if (totalMatches >= maxResults) break; + + idx = Math.max(end, found + 1); + } + } + } + } + + return { + results: Array.from(results.values()), + backend: 'fallback', + warning, + }; +} + +function pushMatch( + results: Map, + uri: vscode.Uri, + lineNumber: number, + startCharacter: number, + endCharacter: number, + previewLine: string +): void { + const uriString = uri.toString(); + + if (!results.has(uriString)) { + results.set(uriString, { + uri: uriString, + relativePath: vscode.workspace.asRelativePath(uri, false), + matches: [], + }); + } + + const fileResult = results.get(uriString)!; + + fileResult.matches.push({ + range: { + startLine: lineNumber, + startCharacter, + endLine: lineNumber, + endCharacter, + }, + preview: previewLine, + lineNumber, + }); } diff --git a/src/utils/autoSave.ts b/src/utils/autoSave.ts new file mode 100644 index 0000000..330bade --- /dev/null +++ b/src/utils/autoSave.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; + +/** + * Best-effort save for a set of URIs. + * - Opens text documents if needed + * - Ignores errors (some URIs may not be text, may be readonly, etc.) + */ +export async function saveUris( + uris: vscode.Uri[] +): Promise<{ savedUris: string[]; failedUris: string[] }> { + const unique = new Map(); + for (const uri of uris) { + unique.set(uri.toString(), uri); + } + + const savedUris: string[] = []; + const failedUris: string[] = []; + + for (const uri of unique.values()) { + try { + const doc = await vscode.workspace.openTextDocument(uri); + const saved = await doc.save(); + if (saved) { + savedUris.push(uri.toString()); + } else { + failedUris.push(uri.toString()); + } + } catch { + failedUris.push(uri.toString()); + } + } + + return { savedUris, failedUris }; +} diff --git a/src/utils/debugSessionRegistry.ts b/src/utils/debugSessionRegistry.ts new file mode 100644 index 0000000..ca1f698 --- /dev/null +++ b/src/utils/debugSessionRegistry.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; + +let initialized = false; +const sessions = new Map(); + +function addSession(session: vscode.DebugSession) { + sessions.set(session.id, session); +} + +function removeSession(session: vscode.DebugSession) { + sessions.delete(session.id); +} + +export function recordDebugSession(session: vscode.DebugSession | undefined | null): void { + if (!session) return; + addSession(session); +} + +export function initDebugSessionRegistry(context: vscode.ExtensionContext): void { + if (initialized) return; + initialized = true; + + if (vscode.debug.activeDebugSession) { + addSession(vscode.debug.activeDebugSession); + } + + context.subscriptions.push( + vscode.debug.onDidStartDebugSession(addSession), + vscode.debug.onDidTerminateDebugSession(removeSession) + ); +} + +export function getKnownDebugSessions(): vscode.DebugSession[] { + return Array.from(sessions.values()); +} + +export function getKnownDebugSessionById(id: string): vscode.DebugSession | undefined { + return sessions.get(id); +} diff --git a/src/utils/lmTools.ts b/src/utils/lmTools.ts new file mode 100644 index 0000000..460f540 --- /dev/null +++ b/src/utils/lmTools.ts @@ -0,0 +1,47 @@ +import * as vscode from 'vscode'; + +export type LanguageModelToolInfo = { + name: string; + description: string; + tags: readonly string[]; + hasInputSchema: boolean; + inputSchemaType?: string; +}; + +export function getLanguageModelToolsSnapshot(): LanguageModelToolInfo[] { + const tools = vscode.lm?.tools ? Array.from(vscode.lm.tools) : []; + return tools + .map((tool) => { + const schema = tool.inputSchema as { type?: unknown } | undefined; + const inputSchemaType = typeof schema?.type === 'string' ? schema.type : undefined; + return { + name: tool.name, + description: tool.description ?? '', + tags: tool.tags ?? [], + hasInputSchema: !!tool.inputSchema, + inputSchemaType, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function groupToolNamesByPrefix(toolNames: string[]): Map { + const groups = new Map(); + + for (const name of toolNames) { + // Common naming patterns: "_", ".", ":" + const match = name.match(/^([^._:]+)[._:]/); + const prefix = match?.[1] ?? '(no-prefix)'; + const list = groups.get(prefix) ?? []; + list.push(name); + groups.set(prefix, list); + } + + for (const [prefix, names] of groups) { + names.sort((a, b) => a.localeCompare(b)); + groups.set(prefix, names); + } + + return new Map([...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))); +} + diff --git a/src/utils/ngrok.test.ts b/src/utils/ngrok.test.ts new file mode 100644 index 0000000..8f5a3c8 --- /dev/null +++ b/src/utils/ngrok.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import * as http from 'http'; +import { getNgrokPublicUrl } from './ngrok'; + +function startServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void) { + return new Promise<{ server: http.Server; baseUrl: string }>((resolve) => { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind test server'); + } + resolve({ server, baseUrl: `http://127.0.0.1:${address.port}` }); + }); + }); +} + +describe('getNgrokPublicUrl', () => { + it('prefers https tunnel when present', async () => { + const { server, baseUrl } = await startServer((req, res) => { + if (req.url !== '/api/tunnels') { + res.statusCode = 404; + res.end(); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + tunnels: [ + { proto: 'http', public_url: 'http://example.ngrok.io' }, + { proto: 'https', public_url: 'https://secure.ngrok.io' }, + ], + }) + ); + }); + + try { + await expect(getNgrokPublicUrl(baseUrl, 1000)).resolves.toBe('https://secure.ngrok.io'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('returns undefined when no tunnels exist', async () => { + const { server, baseUrl } = await startServer((req, res) => { + if (req.url !== '/api/tunnels') { + res.statusCode = 404; + res.end(); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ tunnels: [] })); + }); + + try { + await expect(getNgrokPublicUrl(baseUrl, 1000)).resolves.toBeUndefined(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('can select tunnel by local port when multiple tunnels exist', async () => { + const { server, baseUrl } = await startServer((req, res) => { + if (req.url !== '/api/tunnels') { + res.statusCode = 404; + res.end(); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + tunnels: [ + { + proto: 'https', + public_url: 'https://wrong-port.ngrok.io', + config: { addr: 'http://127.0.0.1:3000' }, + }, + { + proto: 'https', + public_url: 'https://right-port.ngrok.io', + config: { addr: '127.0.0.1:4000' }, + }, + ], + }) + ); + }); + + try { + await expect(getNgrokPublicUrl(baseUrl, 1000, 4000)).resolves.toBe('https://right-port.ngrok.io'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/src/utils/ngrok.ts b/src/utils/ngrok.ts new file mode 100644 index 0000000..fcb12e6 --- /dev/null +++ b/src/utils/ngrok.ts @@ -0,0 +1,80 @@ +import * as http from 'http'; +import * as https from 'https'; +import { URL } from 'url'; + +type NgrokTunnel = { + public_url?: string; + proto?: string; + config?: { + addr?: string; + }; +}; + +type NgrokTunnelsResponse = { + tunnels?: NgrokTunnel[]; +}; + +function requestText(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const requester = parsed.protocol === 'https:' ? https : http; + + const request = requester.request( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + } + ); + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error('Request timed out')); + }); + + request.on('error', reject); + request.end(); + }); +} + +export async function getNgrokPublicUrl( + adminBaseUrl: string = 'http://127.0.0.1:4040', + timeoutMs: number = 750, + localPort?: number +): Promise { + const base = adminBaseUrl.endsWith('/') ? adminBaseUrl.slice(0, -1) : adminBaseUrl; + const body = await requestText(`${base}/api/tunnels`, timeoutMs); + + const parsed = JSON.parse(body) as NgrokTunnelsResponse; + const tunnels = parsed.tunnels || []; + + const normalizedTunnels = + typeof localPort === 'number' + ? tunnels.filter((t) => { + const addr = t.config?.addr; + if (!addr) { + return false; + } + const normalized = addr.trim().replace(/^https?:\/\//i, ''); + return normalized.endsWith(`:${localPort}`); + }) + : tunnels; + + const httpsTunnel = normalizedTunnels.find( + (t) => t.proto === 'https' && typeof t.public_url === 'string' + ); + if (httpsTunnel?.public_url) { + return httpsTunnel.public_url; + } + + const anyTunnel = normalizedTunnels.find((t) => typeof t.public_url === 'string'); + return anyTunnel?.public_url; +} diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000..4d1411c --- /dev/null +++ b/testing/README.md @@ -0,0 +1,147 @@ +## Remote agent instructions: tool-call tests + + +### 1) Tool-by-tool smoke test order (sequential) + +Goal: run one `tools/call` per tool, in a fixed order, to ensure each tool is reachable and its required params validate. + +Set these local variables on the machine running VS Code + the MCP server: + +- `REPO_ROOT`: absolute path to the repo root (example: `/home/zeynab/VSC-MCPServer`) +- `TEST_URI`: a file in the workspace (example: `$REPO_ROOT/src/server/mcpServer.ts`) + +Recommended call order: + +1) `initialize` +2) `tools/list` +3) Run `tools/call` sequentially for each tool (below) + +If you only need “which tools, in order”, call them in this exact sequence: + +1) `open_folder` +2) `get_open_folders` +3) `document_symbols` +4) `workspace_symbols` +5) `go_to_definition` +6) `find_references` +7) `hover_info` +8) `diagnostics` +9) `call_hierarchy` +10) `get_completions` +11) `get_signature_help` +12) `get_type_hierarchy` +13) `get_code_actions` +14) `get_document_highlights` +15) `get_folding_ranges` +16) `get_inlay_hints` +17) `get_semantic_tokens` +18) `get_code_lens` +19) `get_document_links` +20) `get_selection_range` +21) `get_document_colors` +22) `search_workspace_files` +23) `search_workspace_text` +24) `format_document` (prefer `dryRun: true`) +25) `format_range` (prefer `dryRun: true`) +26) `organize_imports` (prefer `dryRun: true`) +27) `rename_symbol` (prefer `dryRun: true`) +28) `apply_code_action` (prefer `dryRun: true`, usually call `get_code_actions` first) +29) `execute_command` (unsafe; gated by config) +30) `get_terminal_output` (requires an `id` from `execute_command`) +31) `preview_url` (may open UI) +32) `text_editor` (writes unless `action=view` or `dryRun: true`) +33) `list_directory` +34) `focus_editor` (may open UI) +35) `list_debug_sessions` +36) `start_debug_session` +37) `restart_debug_session` +38) `stop_debug_session` +39) `list_vscode_commands` +40) `execute_vscode_command` (unsafe; gated by config) + +Notes: +- Tools marked “stateful/unsafe” may change VS Code state or execute commands. Run them only in a disposable workspace. +- For any tool that needs a `(line, character)`, start with `line: 0, character: 0`. If the tool returns empty results, that’s OK for a smoke test; the call should still succeed. +- For any tool that needs a range, start with `startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 10`. +- For “apply” tools, prefer `dryRun: true`. +- If your agent/client “reconnects” (new session / re-initialize), any previously discovered tool endpoints (e.g. `/vscode/link_/...`) may become stale. Always re-run `tools/list` (or the client’s tool discovery) and use the freshly returned tool names/endpoints. + +#### Tool list (keys to provide in `params.arguments`) + +Use these tool names with `method: "tools/call"` and set `params.name` to the tool name. + +| Tool | Params (keys) | +|---|---| +| open_folder | folderPath, newWindow | +| get_open_folders | | +| document_symbols | uri, query | +| workspace_symbols | query, maxResults | +| go_to_definition | uri, line, character | +| find_references | uri, line, character, includeDeclaration | +| hover_info | uri, line, character | +| diagnostics | uri, severityFilter | +| call_hierarchy | uri, line, character, direction | +| get_completions | uri, line, character, triggerCharacter | +| get_signature_help | uri, line, character | +| get_type_hierarchy | uri, line, character, direction | +| get_code_actions | uri, startLine, startCharacter, endLine, endCharacter, kind | +| get_document_highlights | uri, line, character | +| get_folding_ranges | uri | +| get_inlay_hints | uri, startLine, startCharacter, endLine, endCharacter | +| get_semantic_tokens | uri | +| get_code_lens | uri | +| get_document_links | uri | +| get_selection_range | uri, line, character | +| get_document_colors | uri | +| search_workspace_files | pattern, maxResults, exclude | +| search_workspace_text | query, isRegex, isCaseSensitive, includePattern, excludePattern, maxResults | +| format_document | uri, dryRun | +| format_range | uri, startLine, startCharacter, endLine, endCharacter, dryRun | +| organize_imports | uri, dryRun | +| rename_symbol | uri, line, character, newName, dryRun | +| apply_code_action | uri, startLine, startCharacter, endLine, endCharacter, actionTitle, kind, dryRun | +| execute_command | command, cwd, timeoutMs, background | +| get_terminal_output | id, clear | +| preview_url | url | +| text_editor | action, uri, content, startLine, startCharacter, endLine, endCharacter, text, dryRun | +| list_directory | directoryPath, maxDepth, includeFiles, includeDirectories, excludeHidden, excludeGlobs, maxEntries | +| focus_editor | uri, startLine, startCharacter, endLine, endCharacter, preserveFocus | +| list_debug_sessions | | +| start_debug_session | workspaceFolderUri, configurationJson | +| restart_debug_session | sessionId | +| stop_debug_session | sessionId, stopAll | +| list_vscode_commands | includeInternal | +| execute_vscode_command | command, argsJson | + +#### Minimal smoke-test argument examples (copy/paste) + +For “read-only” tools, these examples should validate and run in most workspaces: + +- `get_open_folders`: `{}` +- `list_directory`: `{ "directoryPath": "$REPO_ROOT", "maxDepth": 2, "maxEntries": 100 }` +- `search_workspace_files`: `{ "pattern": "**/*.ts", "maxResults": 20, "exclude": "**/node_modules/**" }` +- `search_workspace_text`: `{ "query": "MCPServer", "isRegex": false, "isCaseSensitive": false, "maxResults": 20 }` +- `workspace_symbols`: `{ "query": "MCPServer", "maxResults": 10 }` +- `document_symbols`: `{ "uri": "$TEST_URI" }` +- `hover_info`: `{ "uri": "$TEST_URI", "line": 0, "character": 0 }` +- `diagnostics`: `{}` (or `{ "uri": "$TEST_URI" }`) + +For tools that need a range: + +- `get_code_actions`: `{ "uri": "$TEST_URI", "startLine": 0, "startCharacter": 0, "endLine": 0, "endCharacter": 10 }` +- `get_inlay_hints`: `{ "uri": "$TEST_URI", "startLine": 0, "startCharacter": 0, "endLine": 20, "endCharacter": 0 }` + +For “apply/write” tools (recommend `dryRun: true`): + +- `format_document`: `{ "uri": "$TEST_URI", "dryRun": true }` +- `organize_imports`: `{ "uri": "$TEST_URI", "dryRun": true }` + +For dependent tools (two-step): + +- `apply_code_action`: + 1) Call `get_code_actions` and pick a returned `title` + 2) Call `apply_code_action` with that `actionTitle` and `dryRun: true` + +- `get_terminal_output`: + 1) Call `execute_command` with `{ "command": "echo hello", "background": true }` and capture returned `id` + 2) Call `get_terminal_output` with `{ "id": "", "clear": true }` diff --git a/testing/sample.ts b/testing/sample.ts new file mode 100644 index 0000000..26f0c63 --- /dev/null +++ b/testing/sample.ts @@ -0,0 +1,13 @@ +export function add(a: number, b: number): number { + return a + b; +} + +export class Greeter { + constructor(private name: string) {} + greet(): string { + return `Hello, ${this.name}`; + } +} + +const g = new Greeter('MCP'); +console.log(add(1, 2), g.greet());