feat: add per-server tool filtering

This commit is contained in:
Peter Steinberger 2026-04-18 21:27:00 +01:00
parent f11e326380
commit cd4f6ab996
No known key found for this signature in database
14 changed files with 312 additions and 15 deletions

View File

@ -4,6 +4,7 @@
### CLI
- Add per-server exact-name tool filtering with `allowedTools` and `blockedTools`, including config serialization and runtime call/list enforcement. (Rebuild of PR #39, thanks @tonylampada)
- Escalate stuck stdio child-process shutdowns after close timeouts instead of treating the timeout as a clean exit. (PR #39, thanks @tonylampada)
- Quote OAuth browser URLs when launching `cmd.exe` on Windows, preserving query parameters such as `redirect_uri`. (PR #136, thanks @cosminilie)
- Document OAuth-protected server config setup with `mcporter config add --auth oauth` and `mcporter auth`. (PR #34, thanks @prateek)

View File

@ -416,6 +416,27 @@ 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
Server definitions can hide or block exact tool names with either `allowedTools` or `blockedTools`:
```jsonc
{
"mcpServers": {
"slack-readonly": {
"baseUrl": "https://example.com/slack/mcp",
"allowedTools": ["channels_list", "conversations_history"],
},
"filesystem-safe": {
"command": "npx -y @modelcontextprotocol/server-filesystem ~/Downloads",
"blockedTools": ["write_file", "delete_file", "move_file"],
},
},
}
```
`allowedTools` is an allowlist: only listed tools appear in `mcporter list` and can be called. An empty array blocks every tool. `blockedTools` is a blocklist: listed tools are hidden and rejected by `mcporter call`. Use exact tool names only, and choose one mode per server.
## Testing and CI
| Command | Purpose |

View File

@ -184,19 +184,21 @@ Top-level structure:
Server definition fields (subset of what `RawEntrySchema` accepts):
| Field | Description |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `description` | Free-form summary printed by `mcporter list`/`config list`. |
| `baseUrl` / `url` / `serverUrl` | HTTPS or HTTP endpoint. `http://` requires `--allow-http` in ad-hoc mode but works in config if you explicitly set it. |
| `command` / `args` | Stdio executable definition (string or array). Arrays are preferred because they avoid shell quoting issues. |
| `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. |
| `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. |
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. |
| `tokenCacheDir` | Directory for OAuth tokens; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` (legacy per-server caches are auto-migrated). Supports `~` expansion. |
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
| `oauthRedirectUrl` | Override the default localhost callback. Useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but dont publish `scopes_supported`. |
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you dont need to call `npx ... auth` manually. |
| Field | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `description` | Free-form summary printed by `mcporter list`/`config list`. |
| `baseUrl` / `url` / `serverUrl` | HTTPS or HTTP endpoint. `http://` requires `--allow-http` in ad-hoc mode but works in config if you explicitly set it. |
| `command` / `args` | Stdio executable definition (string or array). Arrays are preferred because they avoid shell quoting issues. |
| `env` | Key/value pairs applied when launching stdio commands. Supports `${VAR}` interpolation and `${VAR:-fallback}` defaults. Existing process env values win over fallbacks. |
| `headers` | Request headers for HTTP/SSE transports. Values can reference `$env:VAR` or `${VAR}` placeholders, which must be set at runtime or mcporter aborts with a helpful error. |
| `auth` | Currently only `oauth` is recognized. Any other string is ignored (treated as undefined) to avoid stale state from other clients. |
| `tokenCacheDir` | Directory for OAuth tokens; still honored, but mcporter now keeps a centralized vault in `~/.mcporter/credentials.json` (legacy per-server caches are auto-migrated). Supports `~` expansion. |
| `clientName` | Optional identifier some servers use for telemetry/audience segmentation. |
| `oauthRedirectUrl` | Override the default localhost callback. Useful when tunneling OAuth through Codespaces or remote dev boxes. |
| `oauthScope` | Optional explicit OAuth scope string. If omitted, mcporter lets the MCP SDK derive scope from server/auth metadata. Use this as an escape hatch for providers that require explicit scopes but dont publish `scopes_supported`. |
| `oauthCommand.args` | For STDIO servers that ship a custom auth subcommand (e.g., Gmail MCP). mcporter will spawn the stdio command with these args when you run `mcporter auth <name>`, so you dont need to call `npx ... auth` manually. |
| `allowedTools` / `allowed_tools` | Optional exact-name allowlist. Only listed tools appear in `mcporter list` and can be called. An empty array blocks all tools. Cannot be combined with `blockedTools`. |
| `blockedTools` / `blocked_tools` | Optional exact-name blocklist. Listed tools are hidden from `mcporter list` and rejected by `mcporter call`. Cannot be combined with `allowedTools`. |
mcporter normalizes headers to include `Accept: application/json, text/event-stream` automatically, matching the runtimes streaming expectations.

View File

@ -219,6 +219,34 @@
}
},
"additionalProperties": false
},
"allowedTools": {
"description": "Only these exact tool names are exposed (camelCase)",
"type": "array",
"items": {
"type": "string"
}
},
"allowed_tools": {
"description": "Only these exact tool names are exposed (snake_case)",
"type": "array",
"items": {
"type": "string"
}
},
"blockedTools": {
"description": "These exact tool names are hidden and blocked (camelCase)",
"type": "array",
"items": {
"type": "string"
}
},
"blocked_tools": {
"description": "These exact tool names are hidden and blocked (snake_case)",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false,

View File

@ -25,6 +25,8 @@ export interface SerializedServerDefinition {
readonly clientName?: string;
readonly oauthRedirectUrl?: string;
readonly oauthScope?: string;
readonly allowedTools?: readonly string[];
readonly blockedTools?: readonly string[];
}
export interface CliArtifactMetadata {
@ -143,6 +145,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
clientName: definition.clientName,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};
}
return {
@ -160,5 +164,7 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
clientName: definition.clientName,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
};
}

View File

@ -11,6 +11,8 @@ export type SerializedServerDefinition = {
clientName?: string;
oauthRedirectUrl?: string;
oauthScope?: string;
allowedTools?: readonly string[];
blockedTools?: readonly string[];
env?: Record<string, string>;
transport: 'http' | 'stdio';
baseUrl?: string;
@ -32,6 +34,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
clientName: definition.clientName,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,
transport: 'http',
baseUrl: definition.command.url.href,
@ -47,6 +51,8 @@ export function serializeDefinition(definition: ServerDefinition): SerializedSer
clientName: definition.clientName,
oauthRedirectUrl: definition.oauthRedirectUrl,
oauthScope: definition.oauthScope,
allowedTools: definition.allowedTools,
blockedTools: definition.blockedTools,
env: definition.env,
transport: 'stdio',
command: definition.command.command,
@ -79,6 +85,14 @@ export function printServerSummary(definition: ServerDefinition): void {
if (definition.auth === 'oauth') {
console.log(` ${label('Auth')}: oauth`);
}
if (definition.allowedTools !== undefined) {
const rendered = definition.allowedTools.length > 0 ? definition.allowedTools.join(', ') : '<none>';
console.log(` ${label('Allowed tools')}: ${rendered}`);
}
if (definition.blockedTools !== undefined) {
const rendered = definition.blockedTools.length > 0 ? definition.blockedTools.join(', ') : '<none>';
console.log(` ${label('Blocked tools')}: ${rendered}`);
}
}
export function printImportSummary(importServers: ServerDefinition[]): void {

View File

@ -191,6 +191,16 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
const tokenCacheDir = typeof def.tokenCacheDir === 'string' ? def.tokenCacheDir : undefined;
const clientName = typeof def.clientName === 'string' ? def.clientName : undefined;
const headers = toStringRecord((def as Record<string, unknown>).headers);
const record = def as Record<string, unknown>;
const allowedTools = getOptionalStringArray(record.allowedTools ?? record.allowed_tools, 'allowedTools');
const blockedTools = getOptionalStringArray(record.blockedTools ?? record.blocked_tools, 'blockedTools');
if (allowedTools !== undefined && blockedTools !== undefined) {
throw new Error(`Server definition '${name}' cannot specify both allowedTools and blockedTools.`);
}
const filters = {
...(allowedTools !== undefined ? { allowedTools } : {}),
...(blockedTools !== undefined ? { blockedTools } : {}),
};
const commandValue = def.command;
if (isCommandSpec(commandValue)) {
@ -202,6 +212,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
auth,
tokenCacheDir,
clientName,
...filters,
};
}
if (typeof commandValue === 'string' && commandValue.trim().length > 0) {
@ -213,6 +224,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
auth,
tokenCacheDir,
clientName,
...filters,
};
}
if (Array.isArray(commandValue) && commandValue.length > 0) {
@ -228,6 +240,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition {
auth,
tokenCacheDir,
clientName,
...filters,
};
}
throw new Error('Server definition must include command information.');
@ -311,6 +324,16 @@ function getStringArray(value: unknown): string[] | undefined {
return entries.length > 0 ? entries : undefined;
}
function getOptionalStringArray(value: unknown, fieldName: string): string[] | undefined {
if (value === undefined) {
return undefined;
}
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
throw new Error(`${fieldName} must be an array of strings.`);
}
return [...value];
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (typeof value !== 'object' || value === null) {
return undefined;

View File

@ -44,6 +44,8 @@ export function normalizeServerEntry(
const lifecycle = resolveLifecycle(name, raw.lifecycle, command);
const logging = normalizeLogging(raw.logging);
const allowedTools = raw.allowedTools ?? raw.allowed_tools;
const blockedTools = raw.blockedTools ?? raw.blocked_tools;
const defaultedOauthCommand =
!oauthCommand && name.toLowerCase() === 'gmail' && command.kind === 'stdio'
@ -65,6 +67,8 @@ export function normalizeServerEntry(
sources,
lifecycle,
logging,
...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
...(blockedTools !== undefined ? { blockedTools: [...blockedTools] } : {}),
};
}

View File

@ -34,6 +34,8 @@ const RawLifecycleSchema = z
export type RawLifecycle = z.infer<typeof RawLifecycleSchema>;
const ToolNamesSchema = z.array(z.string()).describe('Exact MCP tool names');
const RawLoggingSchema = z
.object({
daemon: z
@ -98,6 +100,21 @@ export const RawEntrySchema = z
.describe('Environment variable name containing the bearer token (snake_case)'),
lifecycle: RawLifecycleSchema.optional(),
logging: RawLoggingSchema,
allowedTools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (camelCase)'),
allowed_tools: ToolNamesSchema.optional().describe('Only these exact tool names are exposed (snake_case)'),
blockedTools: ToolNamesSchema.optional().describe('These exact tool names are hidden and blocked (camelCase)'),
blocked_tools: ToolNamesSchema.optional().describe('These exact tool names are hidden and blocked (snake_case)'),
})
.superRefine((entry, ctx) => {
const hasAllowed = entry.allowedTools !== undefined || entry.allowed_tools !== undefined;
const hasBlocked = entry.blockedTools !== undefined || entry.blocked_tools !== undefined;
if (hasAllowed && hasBlocked) {
ctx.addIssue({
code: 'custom',
message: 'Specify either allowedTools or blockedTools, not both.',
path: ['allowedTools'],
});
}
})
.describe('MCP server definition supporting both HTTP/SSE and stdio transports');
@ -167,6 +184,10 @@ export interface ServerDefinition {
readonly sources?: readonly ServerSource[];
readonly lifecycle?: ServerLifecycle;
readonly logging?: ServerLoggingOptions;
/** When specified, only these exact tool names are exposed. Empty array blocks all tools. */
readonly allowedTools?: readonly string[];
/** When specified, these exact tool names are hidden and blocked. Cannot be combined with allowedTools. */
readonly blockedTools?: readonly string[];
}
export interface LoadConfigOptions {

View File

@ -9,6 +9,7 @@ import { shouldResetConnection } from './runtime/errors.js';
import { resolveOAuthTimeoutFromEnv } from './runtime/oauth.js';
import { type ClientContext, createClientContext } from './runtime/transport.js';
import { normalizeTimeout, raceWithTimeout } from './runtime/utils.js';
import { filterTools, isToolAllowed, validateToolFilters } from './tool-filters.js';
const PACKAGE_NAME = 'mcporter';
// Keep version in one place by reading package.json; fall back gracefully when bundled without it (e.g., bun bundle).
@ -111,6 +112,9 @@ class McpRuntime implements Runtime {
private readonly oauthTimeoutMs?: number;
constructor(servers: ServerDefinition[], options: RuntimeOptions = {}) {
for (const server of servers) {
validateToolFilters(server.name, server);
}
this.definitions = new Map(servers.map((entry) => [entry.name, entry]));
this.logger = options.logger ?? createConsoleLogger();
this.clientInfo = options.clientInfo ?? {
@ -140,6 +144,7 @@ class McpRuntime implements Runtime {
}
registerDefinition(definition: ServerDefinition, options: { overwrite?: boolean } = {}): void {
validateToolFilters(definition.name, definition);
if (!options.overwrite && this.definitions.has(definition.name)) {
throw new Error(`MCP server '${definition.name}' already exists.`);
}
@ -172,7 +177,7 @@ class McpRuntime implements Runtime {
cursor = response.nextCursor ?? undefined;
} while (cursor);
return tools;
return filterTools(tools, this.definitions.get(server.trim()));
} 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.
@ -189,6 +194,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> {
const definition = this.definitions.get(server.trim());
if (definition && !isToolAllowed(toolName, definition)) {
throw new Error(
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
);
}
try {
const { client } = await this.connect(server);
const params: CallToolRequest['params'] = {

30
src/tool-filters.ts Normal file
View File

@ -0,0 +1,30 @@
export interface ToolFilterConfig {
readonly allowedTools?: readonly string[];
readonly blockedTools?: readonly string[];
}
export function validateToolFilters(name: string, filter: ToolFilterConfig): void {
if (filter.allowedTools !== undefined && filter.blockedTools !== undefined) {
throw new Error(`Server '${name}' cannot specify both allowedTools and blockedTools.`);
}
}
export function isToolAllowed(toolName: string, filter: ToolFilterConfig | undefined): boolean {
if (!filter) {
return true;
}
if (filter.allowedTools !== undefined) {
return filter.allowedTools.includes(toolName);
}
if (filter.blockedTools !== undefined) {
return !filter.blockedTools.includes(toolName);
}
return true;
}
export function filterTools<T extends { readonly name: string }>(
tools: readonly T[],
filter: ToolFilterConfig | undefined
): T[] {
return tools.filter((tool) => isToolAllowed(tool.name, filter));
}

View File

@ -18,6 +18,7 @@ describe('config render helpers', () => {
clientName: 'mcporter',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
allowedTools: ['read'],
env: { FOO: 'bar' },
};
@ -32,6 +33,7 @@ describe('config render helpers', () => {
clientName: 'mcporter',
oauthRedirectUrl: 'https://example.com/callback',
oauthScope: 'openid profile',
allowedTools: ['read'],
env: { FOO: 'bar' },
source: { kind: 'import', path: '/tmp/source.json' },
});
@ -46,6 +48,7 @@ describe('config render helpers', () => {
args: ['--version'],
cwd: '/tmp',
},
blockedTools: ['write'],
};
const payload = serializeDefinition(definition);
@ -56,6 +59,7 @@ describe('config render helpers', () => {
args: ['--version'],
cwd: '/tmp',
name: 'stdio-server',
blockedTools: ['write'],
});
});
});

View File

@ -10,7 +10,7 @@ describe('generated config schema', () => {
expect(checkedIn).toEqual(generated);
});
it('includes top-level $schema and oauthScope properties', async () => {
it('includes top-level $schema, oauthScope, and tool filter properties', async () => {
const schemaPath = new URL('../mcporter.schema.json', import.meta.url);
const schema = JSON.parse(await fs.readFile(schemaPath, 'utf8')) as {
$schema?: string;
@ -25,5 +25,9 @@ describe('generated config schema', () => {
const entryProperties = mcpServers?.additionalProperties?.properties;
expect(entryProperties?.oauthScope).toBeDefined();
expect(entryProperties?.oauth_scope).toBeDefined();
expect(entryProperties?.allowedTools).toBeDefined();
expect(entryProperties?.allowed_tools).toBeDefined();
expect(entryProperties?.blockedTools).toBeDefined();
expect(entryProperties?.blocked_tools).toBeDefined();
});
});

128
tests/tool-filters.test.ts Normal file
View File

@ -0,0 +1,128 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadServerDefinitions, type ServerDefinition } from '../src/config.js';
import { createRuntime } from '../src/runtime.js';
function httpDefinition(name: string, filters: Partial<Pick<ServerDefinition, 'allowedTools' | 'blockedTools'>> = {}) {
return {
name,
command: { kind: 'http', url: new URL(`https://example.com/${name}`) },
...filters,
} satisfies ServerDefinition;
}
async function writeConfig(config: unknown): Promise<string> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-tool-filters-'));
const configPath = path.join(tempDir, 'mcporter.json');
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
return configPath;
}
describe('tool filter config', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('normalizes camelCase and snake_case filter fields', async () => {
const configPath = await writeConfig({
imports: [],
mcpServers: {
allow: {
url: 'https://example.com/allow',
allowed_tools: ['read'],
},
block: {
url: 'https://example.com/block',
blockedTools: ['write'],
},
none: {
url: 'https://example.com/none',
allowedTools: [],
},
},
});
const definitions = await loadServerDefinitions({ configPath });
expect(definitions.find((entry) => entry.name === 'allow')?.allowedTools).toEqual(['read']);
expect(definitions.find((entry) => entry.name === 'block')?.blockedTools).toEqual(['write']);
expect(definitions.find((entry) => entry.name === 'none')?.allowedTools).toEqual([]);
});
it('rejects ambiguous allowlist and blocklist config', async () => {
const configPath = await writeConfig({
imports: [],
mcpServers: {
unsafe: {
url: 'https://example.com/unsafe',
allowedTools: ['read'],
blockedTools: ['write'],
},
},
});
await expect(loadServerDefinitions({ configPath })).rejects.toThrow(
'Specify either allowedTools or blockedTools, not both.'
);
});
});
describe('runtime tool filtering', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('filters listTools with an allowlist, including an empty allowlist', async () => {
const runtime = await createRuntime({
servers: [httpDefinition('allow', { allowedTools: ['read'] }), httpDefinition('empty', { allowedTools: [] })],
});
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const listTools = vi.fn().mockResolvedValue({
tools: [{ name: 'read' }, { name: 'write' }, { name: 'delete' }],
});
vi.spyOn(runtime, 'connect').mockResolvedValue({
client: { listTools },
transport: { close: vi.fn().mockResolvedValue(undefined) },
definition: runtime.getDefinition('allow'),
oauthSession: undefined,
} as unknown as ClientContext);
await expect(runtime.listTools('allow')).resolves.toEqual([{ name: 'read' }]);
await expect(runtime.listTools('empty')).resolves.toEqual([]);
});
it('filters listTools and rejects callTool with a blocklist before connecting', async () => {
const runtime = await createRuntime({
servers: [httpDefinition('filtered', { blockedTools: ['write'] })],
});
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
const listTools = vi.fn().mockResolvedValue({
tools: [{ name: 'read' }, { name: 'write' }, { name: 'delete' }],
});
const callTool = vi.fn().mockResolvedValue({ ok: true });
const connect = vi.spyOn(runtime, 'connect').mockResolvedValue({
client: { listTools, callTool },
transport: { close: vi.fn().mockResolvedValue(undefined) },
definition: runtime.getDefinition('filtered'),
oauthSession: undefined,
} as unknown as ClientContext);
await expect(runtime.listTools('filtered')).resolves.toEqual([{ name: 'read' }, { name: 'delete' }]);
connect.mockClear();
await expect(runtime.callTool('filtered', 'write')).rejects.toThrow(
"Tool 'write' is not accessible on server 'filtered'"
);
expect(connect).not.toHaveBeenCalled();
expect(callTool).not.toHaveBeenCalled();
});
it('rejects programmatic definitions that specify both filter modes', async () => {
await expect(
createRuntime({
servers: [httpDefinition('both', { allowedTools: ['read'], blockedTools: ['write'] })],
})
).rejects.toThrow("Server 'both' cannot specify both allowedTools and blockedTools.");
});
});