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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,49 @@ mcporter config --config ~/.mcporter/mcporter.json add global-server https://api

Set `MCPORTER_CONFIG=~/.mcporter/mcporter.json` in your shell profile when you want that file to be the default everywhere (handy for `npx mcporter …` runs).

### Tool Filtering (allowedTools / blockedTools)

You can restrict which tools are accessible from a server by adding `allowedTools` (allowlist) or `blockedTools` (blocklist) to any server definition. This is useful for:

- **Security:** Prevent AI agents from accessing dangerous tools (e.g., `send_message`, `delete_*`)
- **Scoping:** Expose only the tools relevant to a specific workflow
- **Safety:** Block write operations while allowing read-only access

```jsonc
{
"mcpServers": {
"slack": {
"command": "npx -y slack-mcp-server@latest --transport stdio",
"env": { "SLACK_MCP_XOXP_TOKEN": "${SLACK_TOKEN}" },
// Only allow read operations (allowlist mode)
"allowedTools": [
"channels_list",
"conversations_history",
"conversations_search_messages"
]
},
"filesystem": {
"command": "npx -y @anthropic/mcp-server-filesystem",
// Block dangerous operations (blocklist mode)
"blockedTools": ["delete_file", "move_file", "write_file"]
}
}
}
```

**How it works:**

- **`allowedTools`** (allowlist): When specified, *only* these tools are accessible. All others are hidden and blocked.
- **`blockedTools`** (blocklist): When specified, these tools are hidden and blocked. All others remain accessible.
- **Precedence:** If both are specified, `allowedTools` takes precedence (blocklist is ignored).
- **Empty arrays:** An empty `allowedTools` blocks all tools; an empty `blockedTools` allows all tools.

Blocked tools are:
1. Hidden from `mcporter list` output
2. Rejected with a clear error message when called via `mcporter call`

Snake_case variants (`allowed_tools`, `blocked_tools`) are also supported for consistency with other config fields.

## Testing and CI

| Command | Purpose |
Expand Down
Binary file modified dist-bun/mcporter-macos-arm64-v0.6.2.tar.gz
Binary file not shown.
6 changes: 6 additions & 0 deletions src/config-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export function normalizeServerEntry(
? { args: ['auth', 'http://localhost:3000/oauth2callback'] }
: oauthCommand;

// Normalize tool filtering options (camelCase takes precedence over snake_case)
const allowedTools = raw.allowedTools ?? raw.allowed_tools;
const blockedTools = raw.blockedTools ?? raw.blocked_tools;

return {
name,
description,
Expand All @@ -63,6 +67,8 @@ export function normalizeServerEntry(
sources,
lifecycle,
logging,
allowedTools,
blockedTools,
};
}

Expand Down
9 changes: 9 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export const RawEntrySchema = z.object({
bearer_token_env: z.string().optional(),
lifecycle: RawLifecycleSchema.optional(),
logging: RawLoggingSchema,
// Tool filtering: allowlist takes precedence over blocklist when both are specified
allowedTools: z.array(z.string()).optional(),
allowed_tools: z.array(z.string()).optional(),
blockedTools: z.array(z.string()).optional(),
blocked_tools: z.array(z.string()).optional(),
});

export const RawConfigSchema = z.object({
Expand Down Expand Up @@ -140,6 +145,10 @@ export interface ServerDefinition {
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;
readonly logging?: ServerLoggingOptions;
/** When specified, only these tools are accessible (allowlist). Takes precedence over blockedTools. */
readonly allowedTools?: readonly string[];
/** When specified, these tools are hidden and cannot be called (blocklist). Ignored if allowedTools is set. */
readonly blockedTools?: readonly string[];
}

export interface LoadConfigOptions {
Expand Down
26 changes: 16 additions & 10 deletions src/runtime-process-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,30 +57,36 @@ async function waitForChildClose(child: ChildProcess, timeoutMs: number): Promis
) {
return;
}
await new Promise<void>((resolve) => {
await new Promise<void>((resolve, reject) => {
let settled = false;
const finish = () => {
const finish = (didExit: boolean) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
if (didExit) {
resolve();
} else {
reject(new Error('timeout'));
}
};
const onExit = () => finish(true);
const onTimeout = () => finish(false);
const cleanup = () => {
child.removeListener('close', finish);
child.removeListener('exit', finish);
child.removeListener('error', finish);
child.removeListener('close', onExit);
child.removeListener('exit', onExit);
child.removeListener('error', onExit);
if (timer) {
clearTimeout(timer);
}
};
child.once('close', finish);
child.once('exit', finish);
child.once('error', finish);
child.once('close', onExit);
child.once('exit', onExit);
child.once('error', onExit);
let timer: NodeJS.Timeout | undefined;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timer = setTimeout(finish, timeoutMs);
timer = setTimeout(onTimeout, timeoutMs);
timer.unref?.();
}
});
Expand Down
62 changes: 61 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ class McpRuntime implements Runtime {
cursor = response.nextCursor ?? undefined;
} while (cursor);

return tools;
// Apply tool filtering based on server definition
const definition = this.definitions.get(server.trim());
return this.filterTools(tools, definition);
} catch (error) {
// Keep-alive STDIO transports often die when Chrome closes; drop the cached client
// so the next call spins up a fresh process instead of reusing the broken handle.
Expand All @@ -189,6 +191,12 @@ class McpRuntime implements Runtime {

// callTool executes a tool using the args provided by the caller.
async callTool(server: string, toolName: string, options: CallOptions = {}): Promise<unknown> {
// Check if tool is blocked before attempting the call
const definition = this.definitions.get(server.trim());
if (definition && !this.isToolAllowed(toolName, definition)) {
throw new Error(`Tool '${toolName}' is not accessible on server '${server}' (blocked by configuration).`);
}

try {
const { client } = await this.connect(server);
const params: CallToolRequest['params'] = {
Expand Down Expand Up @@ -311,6 +319,58 @@ class McpRuntime implements Runtime {
this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`);
}
}

/**
* Check if a tool is allowed based on server's allowedTools/blockedTools configuration.
* - If allowedTools is specified (even empty), only tools in that list are allowed (allowlist mode).
* An empty allowedTools array means ALL tools are blocked.
* - If blockedTools is specified (and allowedTools is not), tools in that list are blocked (blocklist mode).
* An empty blockedTools array means ALL tools are allowed.
* - If neither is specified, all tools are allowed.
*/
private isToolAllowed(toolName: string, definition: ServerDefinition | undefined): boolean {
if (!definition) {
return true;
}

// Allowlist takes precedence: if specified (even empty), only listed tools are allowed
// Empty allowedTools = block all tools
if (definition.allowedTools !== undefined) {
return definition.allowedTools.includes(toolName);
}

// Blocklist: if specified, listed tools are blocked
// Empty blockedTools = allow all tools
if (definition.blockedTools !== undefined) {
return !definition.blockedTools.includes(toolName);
}

// No filtering configured
return true;
}

/**
* Filter tools based on server's allowedTools/blockedTools configuration.
*/
private filterTools(tools: ServerToolInfo[], definition: ServerDefinition | undefined): ServerToolInfo[] {
if (!definition) {
return tools;
}

// Allowlist takes precedence: if specified (even empty), only listed tools are shown
// Empty allowedTools = return no tools
if (definition.allowedTools !== undefined) {
return tools.filter((tool) => definition.allowedTools?.includes(tool.name));
}

// Blocklist: if specified, listed tools are hidden
// Empty blockedTools = return all tools
if (definition.blockedTools !== undefined) {
return tools.filter((tool) => !definition.blockedTools?.includes(tool.name));
}

return tools;
}
}

// createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL.
Expand Down
26 changes: 16 additions & 10 deletions src/sdk-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,26 +128,32 @@ function waitForChildClose(child: MaybeChildProcess | undefined, timeoutMs: numb
) {
return Promise.resolve();
}
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
let settled = false;
const swallowProcessError = () => {};
try {
child.on?.('error', swallowProcessError);
} catch {
// ignore
}
const finish = () => {
const finish = (didExit: boolean) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
if (didExit) {
resolve();
} else {
reject(new Error('timeout'));
}
};
const onExit = () => finish(true);
const onTimeout = () => finish(false);
const cleanup = () => {
child.removeListener('exit', finish);
child.removeListener('close', finish);
child.removeListener('error', finish);
child.removeListener('exit', onExit);
child.removeListener('close', onExit);
child.removeListener('error', onExit);
try {
child.removeListener?.('error', swallowProcessError);
} catch {
Expand All @@ -157,12 +163,12 @@ function waitForChildClose(child: MaybeChildProcess | undefined, timeoutMs: numb
clearTimeout(timer);
}
};
child.once('exit', finish);
child.once('close', finish);
child.once('error', finish);
child.once('exit', onExit);
child.once('close', onExit);
child.once('error', onExit);
let timer: NodeJS.Timeout | undefined;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timer = setTimeout(finish, timeoutMs);
timer = setTimeout(onTimeout, timeoutMs);
timer.unref?.();
}
});
Expand Down
99 changes: 99 additions & 0 deletions tests/tool-filtering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import type { ServerDefinition } from '../src/config-schema.js';
import { createRuntime } from '../src/runtime.js';

/**
* Tests for tool filtering functionality (allowedTools / blockedTools).
*
* This feature was implemented by Jarbas (AI assistant) for tonylampada.
*/

// Mock server definition factory
function createMockDefinition(
name: string,
options: { allowedTools?: string[]; blockedTools?: string[] } = {}
): ServerDefinition {
return {
name,
command: {
kind: 'http',
url: new URL('https://example.com/mcp'),
},
allowedTools: options.allowedTools,
blockedTools: options.blockedTools,
};
}

describe('tool filtering configuration', () => {
it('accepts allowedTools in server definition', () => {
const def = createMockDefinition('test', { allowedTools: ['read', 'list'] });
expect(def.allowedTools).toEqual(['read', 'list']);
});

it('accepts blockedTools in server definition', () => {
const def = createMockDefinition('test', { blockedTools: ['delete', 'write'] });
expect(def.blockedTools).toEqual(['delete', 'write']);
});

it('accepts both allowedTools and blockedTools', () => {
const def = createMockDefinition('test', {
allowedTools: ['read'],
blockedTools: ['delete'],
});
expect(def.allowedTools).toEqual(['read']);
expect(def.blockedTools).toEqual(['delete']);
});
});

describe('runtime tool filtering', () => {
it('creates runtime with filtered server definitions', async () => {
const servers: ServerDefinition[] = [
createMockDefinition('allowed-only', { allowedTools: ['tool1', 'tool2'] }),
createMockDefinition('blocked-only', { blockedTools: ['tool3'] }),
createMockDefinition('no-filter'),
];

const runtime = await createRuntime({ servers });

// Verify definitions are preserved
const allowedDef = runtime.getDefinition('allowed-only');
expect(allowedDef.allowedTools).toEqual(['tool1', 'tool2']);

const blockedDef = runtime.getDefinition('blocked-only');
expect(blockedDef.blockedTools).toEqual(['tool3']);

const noFilterDef = runtime.getDefinition('no-filter');
expect(noFilterDef.allowedTools).toBeUndefined();
expect(noFilterDef.blockedTools).toBeUndefined();

await runtime.close();
});
});

describe('tool filtering logic', () => {
// These tests verify the filtering logic without needing actual MCP connections

it('allowedTools allowlist takes precedence over blockedTools', () => {
// When both are specified, allowedTools should be the only filter applied
const def = createMockDefinition('test', {
allowedTools: ['read'],
blockedTools: ['read'], // This should be ignored
});

// The tool 'read' is in allowedTools, so it should be allowed
// even though it's also in blockedTools
expect(def.allowedTools).toContain('read');
});

it('empty allowedTools array should block all tools', () => {
const def = createMockDefinition('test', { allowedTools: [] });
expect(def.allowedTools).toEqual([]);
expect(def.allowedTools?.length).toBe(0);
});

it('empty blockedTools array should allow all tools', () => {
const def = createMockDefinition('test', { blockedTools: [] });
expect(def.blockedTools).toEqual([]);
expect(def.blockedTools?.length).toBe(0);
});
});