Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions src/commands/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ import {
import {
type McpServersConfig,
type ServerConfig,
findDisabledMatch,
getServerConfig,
loadConfig,
loadDisabledTools,
} from '../config.js';
import {
ErrorCode,
formatCliError,
invalidJsonArgsError,
invalidTargetError,
serverConnectionError,
toolDisabledError,
toolExecutionError,
toolNotFoundError,
} from '../errors.js';
Expand Down Expand Up @@ -132,6 +135,24 @@ export async function callCommand(options: CallOptions): Promise<void> {
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);
Expand Down
13 changes: 10 additions & 3 deletions src/commands/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -198,27 +200,32 @@ export async function grepCommand(options: GrepOptions): Promise<void> {
}
}

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(
`Warning: ${failedServers.length} server(s) failed to connect: ${failedServers.join(', ')}`,
);
}

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,
inputSchema: r.tool.inputSchema,
}));
console.log(formatJson(jsonOutput));
} else {
console.log(formatSearchResults(allResults, options.withDescriptions));
console.log(formatSearchResults(filteredResults, options.withDescriptions));
}
}
39 changes: 35 additions & 4 deletions src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,13 +83,37 @@ export async function infoCommand(options: InfoOptions): Promise<void> {
}

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),
Expand All @@ -110,12 +137,16 @@ export async function infoCommand(options: InfoOptions): Promise<void> {
// 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,
Expand All @@ -127,7 +158,7 @@ export async function infoCommand(options: InfoOptions): Promise<void> {
formatServerDetails(
serverName,
serverConfig,
tools,
filteredTools,
options.withDescriptions,
),
);
Expand Down
10 changes: 9 additions & 1 deletion src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -123,9 +125,15 @@ export async function listCommand(options: ListOptions): Promise<void> {
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,
Expand Down
69 changes: 69 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, string>> {
const patterns = new Map<string, string>();

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<string, string>,
): DisabledToolsMatch | undefined {
for (const [pattern, source] of patterns) {
if (globMatch(pattern, toolPath)) {
return { pattern, source };
}
}
return undefined;
}
14 changes: 14 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
37 changes: 37 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
listServerNames,
isHttpServer,
isStdioServer,
loadDisabledTools,
findDisabledMatch,
} from '../src/config';

describe('config', () => {
Expand Down Expand Up @@ -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);
});
});
});