diff --git a/README.md b/README.md index 1ab6ba5..7f5eb88 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,37 @@ The CLI searches for configuration in this order: | `MCP_MAX_RETRIES` | Retry attempts for transient errors (0 = disable) | `3` | | `MCP_RETRY_DELAY` | Base retry delay (milliseconds) | `1000` | | `MCP_STRICT_ENV` | Error on missing `${VAR}` in config | `true` | +| `MCP_DISABLED_TOOLS` | Comma-separated patterns to disable | (none) | + +### Disabled Tools + +Block specific tools from being called or listed. Patterns support `*` wildcards. + +**File locations (all merged):** + +| Path | Scope | +|------|-------| +| `~/.config/mcp/disabled_tools` | Global | +| `~/.mcp_disabled_tools` | Global | +| `./mcp_disabled_tools` | Project | + +**File format:** + +``` +# One pattern per line +filesystem/write_file # Exact match +filesystem/delete_* # Glob pattern +*/dangerous_* # Any server +github/* # Entire server +``` + +**Error output:** + +``` +Error [TOOL_DISABLED]: Tool "filesystem/write_file" is disabled + Details: Matched pattern "filesystem/*" from ~/.config/mcp/disabled_tools + Suggestion: Use alternative tools or approaches to complete this task +``` ## Using with AI Agents diff --git a/src/commands/call.ts b/src/commands/call.ts index 7317ce7..c545f15 100644 --- a/src/commands/call.ts +++ b/src/commands/call.ts @@ -19,8 +19,10 @@ import { import { type McpServersConfig, type ServerConfig, + findDisabledMatch, getServerConfig, loadConfig, + loadDisabledTools, } from '../config.js'; import { ErrorCode, @@ -28,6 +30,7 @@ import { invalidJsonArgsError, invalidTargetError, serverConnectionError, + toolDisabledError, toolExecutionError, toolNotFoundError, } from '../errors.js'; @@ -132,6 +135,24 @@ export async function callCommand(options: CallOptions): Promise { process.exit(ErrorCode.CLIENT_ERROR); } + const disabledPatterns = await loadDisabledTools(); + const disabledMatch = findDisabledMatch( + `${serverName}/${toolName}`, + disabledPatterns, + ); + if (disabledMatch) { + console.error( + formatCliError( + toolDisabledError( + `${serverName}/${toolName}`, + disabledMatch.pattern, + disabledMatch.source, + ), + ), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + let serverConfig: ServerConfig; try { serverConfig = getServerConfig(config, serverName); diff --git a/src/commands/grep.ts b/src/commands/grep.ts index 29d57ee..9a230c2 100644 --- a/src/commands/grep.ts +++ b/src/commands/grep.ts @@ -12,9 +12,11 @@ import { } from '../client.js'; import { type McpServersConfig, + findDisabledMatch, getServerConfig, listServerNames, loadConfig, + loadDisabledTools, } from '../config.js'; import { ErrorCode } from '../errors.js'; import { formatJson, formatSearchResults } from '../output.js'; @@ -198,6 +200,11 @@ export async function grepCommand(options: GrepOptions): Promise { } } + const disabledPatterns = await loadDisabledTools(); + const filteredResults = allResults.filter( + (r) => !findDisabledMatch(`${r.server}/${r.tool.name}`, disabledPatterns), + ); + // Show failed servers warning if (failedServers.length > 0) { console.error( @@ -205,13 +212,13 @@ export async function grepCommand(options: GrepOptions): Promise { ); } - if (allResults.length === 0) { + if (filteredResults.length === 0) { console.log(`No tools found matching "${options.pattern}"`); return; } if (options.json) { - const jsonOutput = allResults.map((r) => ({ + const jsonOutput = filteredResults.map((r) => ({ server: r.server, tool: r.tool.name, description: r.tool.description, @@ -219,6 +226,6 @@ export async function grepCommand(options: GrepOptions): Promise { })); console.log(formatJson(jsonOutput)); } else { - console.log(formatSearchResults(allResults, options.withDescriptions)); + console.log(formatSearchResults(filteredResults, options.withDescriptions)); } } diff --git a/src/commands/info.ts b/src/commands/info.ts index 7da7d78..6e030c6 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -3,17 +3,20 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { connectToServer, getTool, listTools, safeClose } from '../client.js'; +import { connectToServer, listTools, safeClose } from '../client.js'; import { type McpServersConfig, type ServerConfig, + findDisabledMatch, getServerConfig, loadConfig, + loadDisabledTools, } from '../config.js'; import { ErrorCode, formatCliError, serverConnectionError, + toolDisabledError, toolNotFoundError, } from '../errors.js'; import { @@ -80,13 +83,37 @@ export async function infoCommand(options: InfoOptions): Promise { } try { + const disabledPatterns = await loadDisabledTools(); + if (toolName) { + const disabledMatch = findDisabledMatch( + `${serverName}/${toolName}`, + disabledPatterns, + ); + if (disabledMatch) { + console.error( + formatCliError( + toolDisabledError( + `${serverName}/${toolName}`, + disabledMatch.pattern, + disabledMatch.source, + ), + ), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + // Show specific tool schema const tools = await listTools(client); const tool = tools.find((t) => t.name === toolName); if (!tool) { - const availableTools = tools.map((t) => t.name); + const availableTools = tools + .filter( + (t) => + !findDisabledMatch(`${serverName}/${t.name}`, disabledPatterns), + ) + .map((t) => t.name); console.error( formatCliError( toolNotFoundError(toolName, serverName, availableTools), @@ -110,12 +137,16 @@ export async function infoCommand(options: InfoOptions): Promise { // Show server details const tools = await listTools(client); + const filteredTools = tools.filter( + (t) => !findDisabledMatch(`${serverName}/${t.name}`, disabledPatterns), + ); + if (options.json) { console.log( formatJson({ name: serverName, config: serverConfig, - tools: tools.map((t) => ({ + tools: filteredTools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, @@ -127,7 +158,7 @@ export async function infoCommand(options: InfoOptions): Promise { formatServerDetails( serverName, serverConfig, - tools, + filteredTools, options.withDescriptions, ), ); diff --git a/src/commands/list.ts b/src/commands/list.ts index 9073288..9036518 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -12,9 +12,11 @@ import { } from '../client.js'; import { type McpServersConfig, + findDisabledMatch, getServerConfig, listServerNames, loadConfig, + loadDisabledTools, } from '../config.js'; import { ErrorCode } from '../errors.js'; import { formatJson, formatServerList } from '../output.js'; @@ -123,9 +125,15 @@ export async function listCommand(options: ListOptions): Promise { concurrencyLimit, ); - // Sort by name to ensure consistent output order servers.sort((a, b) => a.name.localeCompare(b.name)); + const disabledPatterns = await loadDisabledTools(); + for (const server of servers) { + server.tools = server.tools.filter( + (t) => !findDisabledMatch(`${server.name}/${t.name}`, disabledPatterns), + ); + } + // Convert errors to tool-like display for human output const displayServers = servers.map((s) => ({ name: s.name, diff --git a/src/config.ts b/src/config.ts index 0b936c4..68d130f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -355,3 +355,72 @@ export function getServerConfig( export function listServerNames(config: McpServersConfig): string[] { return Object.keys(config.mcpServers); } + +export interface DisabledToolsMatch { + pattern: string; + source: string; +} + +function globMatch(pattern: string, str: string): boolean { + const regex = new RegExp( + `^${pattern + .split('*') + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*')}$`, + ); + return regex.test(str); +} + +function getDisabledToolsPaths(): string[] { + const home = homedir(); + return [ + join(home, '.config', 'mcp', 'disabled_tools'), + join(home, '.mcp_disabled_tools'), + resolve('./mcp_disabled_tools'), + ]; +} + +function parseDisabledToolsFile(content: string): string[] { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +} + +export async function loadDisabledTools(): Promise> { + const patterns = new Map(); + + for (const path of getDisabledToolsPaths()) { + if (existsSync(path)) { + const content = await Bun.file(path).text(); + for (const pattern of parseDisabledToolsFile(content)) { + patterns.set(pattern, path); + } + debug(`Loaded ${patterns.size} disabled tool patterns from ${path}`); + } + } + + const envPatterns = process.env.MCP_DISABLED_TOOLS; + if (envPatterns) { + for (const pattern of envPatterns + .split(',') + .map((p) => p.trim()) + .filter(Boolean)) { + patterns.set(pattern, 'MCP_DISABLED_TOOLS'); + } + } + + return patterns; +} + +export function findDisabledMatch( + toolPath: string, + patterns: Map, +): DisabledToolsMatch | undefined { + for (const [pattern, source] of patterns) { + if (globMatch(pattern, toolPath)) { + return { pattern, source }; + } + } + return undefined; +} diff --git a/src/errors.ts b/src/errors.ts index 00fd641..54354e2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -254,3 +254,17 @@ export function missingArgumentError( suggestion: `Run 'mcp-cli --help' for usage examples`, }; } + +export function toolDisabledError( + toolPath: string, + pattern: string, + source: string, +): CliError { + return { + code: ErrorCode.CLIENT_ERROR, + type: 'TOOL_DISABLED', + message: `Tool "${toolPath}" is disabled`, + details: `Matched pattern "${pattern}" from ${source}`, + suggestion: 'Use alternative tools or approaches to complete this task', + }; +} diff --git a/tests/config.test.ts b/tests/config.test.ts index a48602c..280daf7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -12,6 +12,8 @@ import { listServerNames, isHttpServer, isStdioServer, + loadDisabledTools, + findDisabledMatch, } from '../src/config'; describe('config', () => { @@ -240,4 +242,39 @@ describe('config', () => { expect(isStdioServer({ url: 'https://example.com' })).toBe(false); }); }); + + describe('disabled tools', () => { + test('findDisabledMatch matches exact patterns', () => { + const patterns = new Map([['server/tool', 'test']]); + expect(findDisabledMatch('server/tool', patterns)).toEqual({ + pattern: 'server/tool', + source: 'test', + }); + expect(findDisabledMatch('server/other', patterns)).toBeUndefined(); + }); + + test('findDisabledMatch supports glob wildcards', () => { + const patterns = new Map([ + ['server/*', 'test1'], + ['*/dangerous', 'test2'], + ]); + expect(findDisabledMatch('server/anything', patterns)?.pattern).toBe('server/*'); + expect(findDisabledMatch('other/dangerous', patterns)?.pattern).toBe('*/dangerous'); + expect(findDisabledMatch('other/safe', patterns)).toBeUndefined(); + }); + + test('loadDisabledTools reads from environment variable', async () => { + process.env.MCP_DISABLED_TOOLS = 'server/tool1,server/tool2'; + const patterns = await loadDisabledTools(); + expect(patterns.get('server/tool1')).toBe('MCP_DISABLED_TOOLS'); + expect(patterns.get('server/tool2')).toBe('MCP_DISABLED_TOOLS'); + delete process.env.MCP_DISABLED_TOOLS; + }); + + test('loadDisabledTools returns empty map when no config', async () => { + delete process.env.MCP_DISABLED_TOOLS; + const patterns = await loadDisabledTools(); + expect(patterns.size).toBe(0); + }); + }); });