From 1fb6e2420641282e815d97e66ed62149e9025a0c Mon Sep 17 00:00:00 2001 From: Eli Oshinsky Date: Tue, 19 Aug 2025 12:32:51 -0400 Subject: [PATCH 1/3] 0.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35a5a61..2de81d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-controller", - "version": "0.1.0", + "version": "0.1.1", "description": "MCP server proxy that forwards JSON RPC communication between MCP clients and target servers", "type": "module", "bin": { From 6b1fd42432d93bbf9ca70c0dc9e98d3f123e77ad Mon Sep 17 00:00:00 2001 From: Eli Oshinsky Date: Tue, 19 Aug 2025 14:06:42 -0400 Subject: [PATCH 2/3] feat: add wildcard tool filtering support with * patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements glob-style wildcard filtering for tool names in both proxy mode and list-tools command. Tool patterns like 'get_*' now match any tool starting with 'get_' such as 'get_logs', 'get_metrics', etc. - Add matchesToolPattern function with proper regex escaping for safe wildcard matching - Update filtering logic in proxy-server.ts and cli.ts to use pattern matching - Maintain full backward compatibility with exact tool name matching - Add comprehensive test suite covering wildcard functionality - Enhance help text with wildcard usage examples - Update existing tests for additional test fixture tool 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/cli.ts | 21 ++- src/proxy-server.ts | 19 +- tests/fixtures/mcp-server.ts | 13 ++ tests/integration.test.ts | 19 ++ tests/list-tools.test.ts | 9 +- tests/wildcard-filtering.test.ts | 310 +++++++++++++++++++++++++++++++ 6 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 tests/wildcard-filtering.test.ts diff --git a/src/cli.ts b/src/cli.ts index 808ceb2..5760e88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,21 @@ import { McpProxyServer } from './proxy-server.js'; import type { ProxyConfig, Tool } from './types.js'; import { TargetServerManager } from './target-server.js'; +function matchesToolPattern(toolName: string, pattern: string): boolean { + // Exact match for patterns without wildcards (backward compatibility) + if (!pattern.includes('*')) { + return toolName === pattern; + } + + // Convert glob pattern to regex with proper escaping + const escapedPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars + .replace(/\*/g, '.*'); // Replace * with .* + + const regex = new RegExp(`^${escapedPattern}$`); + return regex.test(toolName); +} + function parseListToolsArguments(args: string[]): ProxyConfig { if (args.length === 0) { process.stderr.write('Error: No target command specified for list-tools\n'); @@ -65,7 +80,9 @@ function parseArguments(): ProxyConfig { if (args.length === 0) { process.stderr.write('Usage: mcp-controller [--enabled-tools ] [--disabled-tools ] [args...]\n'); process.stderr.write(' mcp-controller list-tools [--enabled-tools ] [--disabled-tools ] [args...]\n'); + process.stderr.write('Tool patterns support wildcards: use * to match any characters (e.g., get_* matches get_logs, get_metrics)\n'); process.stderr.write('Example: mcp-controller --enabled-tools add,subtract bun run server.ts\n'); + process.stderr.write('Example: mcp-controller --enabled-tools "get_*,list_*" bun run server.ts\n'); process.stderr.write('Example: mcp-controller list-tools bun run server.ts\n'); process.stderr.write('Example: mcp-controller --disabled-tools dangerous-tool bun run server.ts\n'); process.exit(1); @@ -201,9 +218,9 @@ async function listTools(config: ProxyConfig): Promise { let tools = response.result.tools || []; if (config.enabledTools) { - tools = tools.filter((tool: Tool) => config.enabledTools!.includes(tool.name)); + tools = tools.filter((tool: Tool) => config.enabledTools!.some(pattern => matchesToolPattern(tool.name, pattern))); } else if (config.disabledTools) { - tools = tools.filter((tool: Tool) => !config.disabledTools!.includes(tool.name)); + tools = tools.filter((tool: Tool) => !config.disabledTools!.some(pattern => matchesToolPattern(tool.name, pattern))); } // Print tools in the requested format diff --git a/src/proxy-server.ts b/src/proxy-server.ts index 7ae351b..f026aa5 100644 --- a/src/proxy-server.ts +++ b/src/proxy-server.ts @@ -1,6 +1,21 @@ import type { ProxyConfig, TargetServerProcess, JsonRpcMessage, ToolsListResult } from './types.js'; import { TargetServerManager } from './target-server.js'; +function matchesToolPattern(toolName: string, pattern: string): boolean { + // Exact match for patterns without wildcards (backward compatibility) + if (!pattern.includes('*')) { + return toolName === pattern; + } + + // Convert glob pattern to regex with proper escaping + const escapedPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars + .replace(/\*/g, '.*'); // Replace * with .* + + const regex = new RegExp(`^${escapedPattern}$`); + return regex.test(toolName); +} + export class McpProxyServer { private targetManager: TargetServerManager; private config: ProxyConfig; @@ -107,9 +122,9 @@ export class McpProxyServer { let filteredTools = result.tools; if (enabledTools) { - filteredTools = filteredTools.filter(tool => enabledTools.includes(tool.name)); + filteredTools = filteredTools.filter(tool => enabledTools.some(pattern => matchesToolPattern(tool.name, pattern))); } else if (disabledTools) { - filteredTools = filteredTools.filter(tool => !disabledTools.includes(tool.name)); + filteredTools = filteredTools.filter(tool => !disabledTools.some(pattern => matchesToolPattern(tool.name, pattern))); } return { diff --git a/tests/fixtures/mcp-server.ts b/tests/fixtures/mcp-server.ts index ab13baf..b829338 100644 --- a/tests/fixtures/mcp-server.ts +++ b/tests/fixtures/mcp-server.ts @@ -44,6 +44,19 @@ server.registerTool( }) ); +// Add a subtraction tool +server.registerTool( + 'subtract', + { + title: 'Subtraction Tool', + description: 'Subtract two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a - b) }], + }) +); + // Add a tool that returns the initialization arguments server.registerTool( 'get-args', diff --git a/tests/integration.test.ts b/tests/integration.test.ts index cd19799..67bbf87 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -241,6 +241,25 @@ describe('MCP Proxy Integration Tests', () => { type: 'object', }, }, + { + name: 'subtract', + title: 'Subtraction Tool', + description: 'Subtract two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { + type: 'number', + }, + b: { + type: 'number', + }, + }, + required: ['a', 'b'], + type: 'object', + }, + }, { name: 'get-args', title: 'Get Arguments Tool', diff --git a/tests/list-tools.test.ts b/tests/list-tools.test.ts index 26a1621..76c0b9f 100644 --- a/tests/list-tools.test.ts +++ b/tests/list-tools.test.ts @@ -24,10 +24,11 @@ describe('List Tools Command Tests', () => { // Should not have errors expect(errorOutput.trim()).toBe(''); - // Should list both tools in the expected format + // Should list all tools in the expected format const lines = output.trim().split('\n'); expect(lines).toEqual([ 'add: Add two numbers', + 'subtract: Subtract two numbers', 'get-args: Returns the command line arguments passed to the server' ]); }); @@ -79,10 +80,11 @@ describe('List Tools Command Tests', () => { // Should not have errors expect(errorOutput.trim()).toBe(''); - // Should only list the non-disabled tool + // Should only list the non-disabled tools const lines = output.trim().split('\n'); expect(lines).toEqual([ - 'add: Add two numbers' + 'add: Add two numbers', + 'subtract: Subtract two numbers' ]); }); @@ -190,6 +192,7 @@ describe('List Tools Command Tests', () => { const lines = output.trim().split('\n'); expect(lines).toEqual([ 'add: Add two numbers', + 'subtract: Subtract two numbers', 'get-args: Returns the command line arguments passed to the server' ]); }); diff --git a/tests/wildcard-filtering.test.ts b/tests/wildcard-filtering.test.ts new file mode 100644 index 0000000..0255ed7 --- /dev/null +++ b/tests/wildcard-filtering.test.ts @@ -0,0 +1,310 @@ +import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; +import path from 'path'; +import { z } from 'zod'; + +const ToolsListResponseSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.number(), + result: z.object({ + tools: z.array(z.object({ + name: z.string(), + description: z.string(), + inputSchema: z.record(z.unknown()), + })), + }), +}); + + +type JsonRpcMessage = { + jsonrpc: '2.0'; + id: number; + method?: string; + params?: Record; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +// Utility function to send JSON-RPC message and get response +async function sendJsonRpcMessage( + process: Bun.Subprocess, + message: JsonRpcMessage +): Promise { + (process.stdin as Bun.FileSink).write(JSON.stringify(message) + '\n'); + + const reader = (process.stdout as ReadableStream).getReader(); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) throw new Error('Stream ended without response'); + + if (value) { + buffer += new TextDecoder().decode(value); + const lines = buffer.split('\n'); + + for (const line of lines) { + if (line.trim()) { + try { + const response = JSON.parse(line.trim()) as JsonRpcMessage; + if (response.id === message.id) { + reader.releaseLock(); + return response; + } + } catch { + // Continue if line isn't valid JSON + continue; + } + } + } + } + } +} + +describe('Wildcard Tool Filtering Tests', () => { + let controllerProcess: Bun.Subprocess; + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + beforeAll(async () => { + // Start controller with wildcard patterns in proxy mode + controllerProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + '--enabled-tools', 'get-*,add', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + + afterAll(async () => { + if (controllerProcess) { + controllerProcess.kill(); + await controllerProcess.exited; + } + }); + + test('should filter tools using wildcard patterns in proxy mode', async () => { + // Initialize the connection + const initializeRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: 'initialize', + params: { + protocolVersion: '0.1.0', + capabilities: {}, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + }; + + const initResponse = await sendJsonRpcMessage(controllerProcess, initializeRequest); + expect(initResponse.error).toBeUndefined(); + + // Request tools list + const toolsRequest = { + jsonrpc: '2.0' as const, + id: 2, + method: 'tools/list', + params: {}, + }; + + const response = await sendJsonRpcMessage(controllerProcess, toolsRequest); + const validatedResponse = ToolsListResponseSchema.parse(response); + + // Should only include tools matching get-* pattern and exact match 'add' + const toolNames = validatedResponse.result.tools.map(tool => tool.name); + + // Verify wildcard matching: should include get-args (matches get-*) + expect(toolNames).toContain('get-args'); + expect(toolNames).toContain('add'); + + // Should NOT include tools that don't match pattern + expect(toolNames).not.toContain('subtract'); + }); +}); + +describe('List-Tools Mode Wildcard Tests', () => { + test('should support wildcard patterns in list-tools mode', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--enabled-tools', 'get-*', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Get the output + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should include tools matching get-* pattern + expect(stdout).toContain('get-args'); + + // Should NOT include tools that don't match + expect(stdout).not.toContain('add:'); + expect(stdout).not.toContain('subtract:'); + }); + + test('should support disabled tools with wildcards', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--disabled-tools', 'get-*', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should NOT include tools matching get-* pattern + expect(stdout).not.toContain('get-args'); + + // Should include tools that don't match the disabled pattern + expect(stdout).toContain('add:'); + expect(stdout).toContain('subtract:'); + }); + + test('should handle exact matches without wildcards (backward compatibility)', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--enabled-tools', 'add', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should include exact match + expect(stdout).toContain('add:'); + + // Should NOT include other tools + expect(stdout).not.toContain('subtract:'); + expect(stdout).not.toContain('get-args:'); + }); + + test('should handle mixed exact and wildcard patterns', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--enabled-tools', 'add,get-*', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should include exact match 'add' + expect(stdout).toContain('add:'); + + // Should include wildcard matches for get-* + expect(stdout).toContain('get-args:'); + + // Should NOT include tools that match neither pattern + expect(stdout).not.toContain('subtract:'); + }); + + test('should handle match-all wildcard pattern', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--enabled-tools', '*', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should include all tools when using * pattern + expect(stdout).toContain('add:'); + expect(stdout).toContain('subtract:'); + expect(stdout).toContain('get-args:'); + }); + + test('should handle complex wildcard patterns', async () => { + const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); + + const listToolsProcess = Bun.spawn([ + 'bun', 'run', 'src/cli.ts', + 'list-tools', + '--enabled-tools', '*-args', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const stdout = await new Response(listToolsProcess.stdout as ReadableStream).text(); + + listToolsProcess.kill(); + await listToolsProcess.exited; + + // Should include tools ending with '-args' + expect(stdout).toContain('get-args:'); + + // Should NOT include tools that don't end with '-args' + expect(stdout).not.toContain('add:'); + expect(stdout).not.toContain('subtract:'); + }); +}); \ No newline at end of file From c68b27d4bdc5d45f01a8bba971425e6f36d9a7ff Mon Sep 17 00:00:00 2001 From: Eli Oshinsky Date: Tue, 19 Aug 2025 15:19:10 -0400 Subject: [PATCH 3/3] refactor: extract utils and improve test organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract matchesToolPattern function to shared utils module - Add reusable test utilities and message builders - Refactor integration tests to use shared test helpers - Update ESLint config with formatting and unused vars rule 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- eslint.config.mjs | 11 +- src/cli.ts | 16 +- src/proxy-server.ts | 16 +- src/utils.ts | 14 + tests/integration.test.ts | 459 ++++++++----------------------- tests/test-messages.ts | 108 ++++++++ tests/test-utils.ts | 102 +++++++ tests/wildcard-filtering.test.ts | 132 ++------- 8 files changed, 371 insertions(+), 487 deletions(-) create mode 100644 src/utils.ts create mode 100644 tests/test-messages.ts create mode 100644 tests/test-utils.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5a5c13e..961b073 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,18 +26,20 @@ export default tseslint.config( 'import-x/no-amd': 'error', 'import-x/no-import-module-exports': 'error', '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-unused-vars': 'off', // General rules 'no-console': 'error', 'prefer-const': 'error', - + // Ban dynamic imports - only top-level import declarations allowed 'no-restricted-syntax': [ 'error', { selector: 'ImportExpression', - message: 'Dynamic imports are not allowed. Use top-level import declarations only.' - } + message: + 'Dynamic imports are not allowed. Use top-level import declarations only.', + }, ], }, }, @@ -47,4 +49,5 @@ export default tseslint.config( 'no-console': 'off', }, } -); \ No newline at end of file +); + diff --git a/src/cli.ts b/src/cli.ts index 5760e88..beba311 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,21 +3,7 @@ import { McpProxyServer } from './proxy-server.js'; import type { ProxyConfig, Tool } from './types.js'; import { TargetServerManager } from './target-server.js'; - -function matchesToolPattern(toolName: string, pattern: string): boolean { - // Exact match for patterns without wildcards (backward compatibility) - if (!pattern.includes('*')) { - return toolName === pattern; - } - - // Convert glob pattern to regex with proper escaping - const escapedPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars - .replace(/\*/g, '.*'); // Replace * with .* - - const regex = new RegExp(`^${escapedPattern}$`); - return regex.test(toolName); -} +import { matchesToolPattern } from './utils.js'; function parseListToolsArguments(args: string[]): ProxyConfig { if (args.length === 0) { diff --git a/src/proxy-server.ts b/src/proxy-server.ts index f026aa5..c552428 100644 --- a/src/proxy-server.ts +++ b/src/proxy-server.ts @@ -1,20 +1,6 @@ import type { ProxyConfig, TargetServerProcess, JsonRpcMessage, ToolsListResult } from './types.js'; import { TargetServerManager } from './target-server.js'; - -function matchesToolPattern(toolName: string, pattern: string): boolean { - // Exact match for patterns without wildcards (backward compatibility) - if (!pattern.includes('*')) { - return toolName === pattern; - } - - // Convert glob pattern to regex with proper escaping - const escapedPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars - .replace(/\*/g, '.*'); // Replace * with .* - - const regex = new RegExp(`^${escapedPattern}$`); - return regex.test(toolName); -} +import { matchesToolPattern } from './utils.js'; export class McpProxyServer { private targetManager: TargetServerManager; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4122671 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,14 @@ +export function matchesToolPattern(toolName: string, pattern: string): boolean { + // Exact match for patterns without wildcards (backward compatibility) + if (!pattern.includes('*')) { + return toolName === pattern; + } + + // Convert glob pattern to regex with proper escaping + const escapedPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars + .replace(/\*/g, '.*'); // Replace * with .* + + const regex = new RegExp(`^${escapedPattern}$`); + return regex.test(toolName); +} \ No newline at end of file diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 67bbf87..a7f3740 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -11,24 +11,20 @@ import { ListPromptsResultSchema, GetPromptResultSchema } from '@modelcontextprotocol/sdk/types.js'; - -type JsonRpcMessage = { - jsonrpc: string; - id?: number; - method: string; - params?: Record; -}; - -type JsonRpcResponse = { - jsonrpc: string; - id: number; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; -}; +import { withMcpCommander, type JsonRpcMessage, type JsonRpcResponse } from './test-utils.js'; +import { + createInitializeRequest, + createInitializedNotification, + createToolsListRequest, + createToolCallRequest, + createResourcesListRequest, + createResourceReadRequest, + createResourceTemplatesListRequest, + createPingRequest, + createPromptsListRequest, + createPromptGetRequest, + createInvalidMethodRequest +} from './test-messages.js'; // Complete response schemas using MCP SDK types const InitializeResponseSchema = JSONRPCResponseSchema.extend({ @@ -144,22 +140,7 @@ describe('MCP Proxy Integration Tests', () => { } test('should initialize MCP connection through proxy', async () => { - const initRequest = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: { - tools: {}, - resources: {}, - }, - clientInfo: { - name: 'test-client', - version: '1.0.0', - }, - }, - }; + const initRequest = createInitializeRequest(); const response = await sendJsonRpcMessage(initRequest); @@ -191,10 +172,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should send initialized notification through proxy', async () => { - const initNotification = { - jsonrpc: '2.0', - method: 'initialized', - }; + const initNotification = createInitializedNotification(); // Notifications don't return responses, but shouldn't error await sendNotification(initNotification); @@ -207,11 +185,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should list tools through proxy', async () => { - const toolsRequest = { - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - }; + const toolsRequest = createToolsListRequest(); const response = await sendJsonRpcMessage(toolsRequest); @@ -277,18 +251,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should call tools through proxy', async () => { - const toolCallRequest = { - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'add', - arguments: { - a: 5, - b: 3, - }, - }, - }; + const toolCallRequest = createToolCallRequest(3, 'add', { a: 5, b: 3 }); const response = await sendJsonRpcMessage(toolCallRequest); @@ -309,15 +272,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should pass command line arguments through proxy to target server', async () => { - const argsRequest = { - jsonrpc: '2.0', - id: 13, - method: 'tools/call', - params: { - name: 'get-args', - arguments: {}, - }, - }; + const argsRequest = createToolCallRequest(13, 'get-args'); const response = await sendJsonRpcMessage(argsRequest); @@ -338,11 +293,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should list resources through proxy', async () => { - const resourcesRequest = { - jsonrpc: '2.0', - id: 4, - method: 'resources/list', - }; + const resourcesRequest = createResourcesListRequest(); const response = await sendJsonRpcMessage(resourcesRequest); @@ -365,14 +316,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should read resources through proxy', async () => { - const resourceReadRequest = { - jsonrpc: '2.0', - id: 5, - method: 'resources/read', - params: { - uri: 'greeting://world', - }, - }; + const resourceReadRequest = createResourceReadRequest(5, 'greeting://world'); const response = await sendJsonRpcMessage(resourceReadRequest); @@ -393,15 +337,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle errors through proxy', async () => { - const invalidToolRequest = { - jsonrpc: '2.0', - id: 6, - method: 'tools/call', - params: { - name: 'nonexistent-tool', - arguments: {}, - }, - }; + const invalidToolRequest = createToolCallRequest(6, 'nonexistent-tool'); const response = await sendJsonRpcMessage(invalidToolRequest); @@ -418,12 +354,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle invalid JSON-RPC requests', async () => { - const invalidRequest = { - jsonrpc: '2.0', - id: 7, - method: 'invalid/method', - params: {}, - }; + const invalidRequest = createInvalidMethodRequest(7); const response = await sendJsonRpcMessage(invalidRequest); @@ -440,18 +371,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle tool call with invalid arguments', async () => { - const invalidArgsRequest = { - jsonrpc: '2.0', - id: 8, - method: 'tools/call', - params: { - name: 'add', - arguments: { - a: 'not-a-number', - b: 3, - }, - }, - }; + const invalidArgsRequest = createToolCallRequest(8, 'add', { a: 'not-a-number', b: 3 }); const response = await sendJsonRpcMessage(invalidArgsRequest); @@ -468,14 +388,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle resource read with invalid URI', async () => { - const invalidUriRequest = { - jsonrpc: '2.0', - id: 9, - method: 'resources/read', - params: { - uri: 'invalid://uri', - }, - }; + const invalidUriRequest = createResourceReadRequest(9, 'invalid://uri'); const response = await sendJsonRpcMessage(invalidUriRequest); @@ -492,11 +405,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should list resource templates through proxy', async () => { - const resourceTemplatesRequest = { - jsonrpc: '2.0', - id: 10, - method: 'resources/templates/list', - }; + const resourceTemplatesRequest = createResourceTemplatesListRequest(); const response = await sendJsonRpcMessage(resourceTemplatesRequest); @@ -518,11 +427,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle ping through proxy', async () => { - const pingRequest = { - jsonrpc: '2.0', - id: 11, - method: 'ping', - }; + const pingRequest = createPingRequest(); const response = await sendJsonRpcMessage(pingRequest); @@ -535,22 +440,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should validate server capabilities were proxied correctly', async () => { - const initRequest = { - jsonrpc: '2.0', - id: 12, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: { - tools: {}, - resources: {}, - }, - clientInfo: { - name: 'test-client', - version: '1.0.0', - }, - }, - }; + const initRequest = createInitializeRequest(12); const response = await sendJsonRpcMessage(initRequest); @@ -582,11 +472,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should list prompts through proxy', async () => { - const promptsRequest = { - jsonrpc: '2.0', - id: 14, - method: 'prompts/list', - }; + const promptsRequest = createPromptsListRequest(); const response = await sendJsonRpcMessage(promptsRequest); @@ -614,17 +500,7 @@ describe('MCP Proxy Integration Tests', () => { }); test('should get prompt with arguments through proxy', async () => { - const promptGetRequest = { - jsonrpc: '2.0', - id: 15, - method: 'prompts/get', - params: { - name: 'generate-greeting', - arguments: { - name: 'Alice', - }, - }, - }; + const promptGetRequest = createPromptGetRequest(15, 'generate-greeting', { name: 'Alice' }); const response = await sendJsonRpcMessage(promptGetRequest); @@ -649,209 +525,104 @@ describe('MCP Proxy Integration Tests', () => { }); describe('MCP Proxy Tool Filtering Tests', () => { - let proxyProcess: Bun.Subprocess; - - const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); - const controllerExecutable = path.resolve('./mcp-controller'); - - afterAll(async () => { - if (proxyProcess) { - proxyProcess.kill(); - await proxyProcess.exited; - } - }); - - // Helper function to send JSON-RPC message and get response - async function sendJsonRpcMessage(message: JsonRpcMessage): Promise { - const messageStr = JSON.stringify(message) + '\n'; - - // Type guard to ensure stdin is available - if (!proxyProcess.stdin || typeof proxyProcess.stdin === 'number') { - throw new Error('Process stdin is not available'); - } - - // Write to stdin (FileSink in Bun) - proxyProcess.stdin.write(messageStr); - - // Type guard to ensure stdout is a ReadableStream - if (!proxyProcess.stdout || typeof proxyProcess.stdout === 'number') { - throw new Error('Process stdout is not available'); - } - - // Read response from stdout - const reader = proxyProcess.stdout.getReader(); - const { value } = await reader.read(); - reader.releaseLock(); - - if (value) { - const responseStr = new TextDecoder().decode(value); - const lines = responseStr.trim().split('\n'); - // Return the last JSON line (ignore any debug output) - for (let i = lines.length - 1; i >= 0; i--) { - try { - return JSON.parse(lines[i]) as JsonRpcResponse; - } catch { - continue; - } - } - } - - throw new Error('No valid JSON response received'); - } - - // Helper function to send notification (no response expected) - async function sendNotification(message: JsonRpcMessage): Promise { - const messageStr = JSON.stringify(message) + '\n'; - - if (!proxyProcess.stdin || typeof proxyProcess.stdin === 'number') { - throw new Error('Process stdin is not available'); - } - - proxyProcess.stdin.write(messageStr); - } describe('enabled tools filtering', () => { - beforeAll(async () => { - // Start proxy with only 'add' tool enabled - proxyProcess = Bun.spawn([ - controllerExecutable, - '--enabled-tools', 'add', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - - // Give the proxy time to start - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Initialize the connection - const initRequest = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: { tools: {}, resources: {} }, - clientInfo: { name: 'test-client', version: '1.0.0' }, - }, - }; - await sendJsonRpcMessage(initRequest); - - const initNotification = { - jsonrpc: '2.0', - method: 'initialized', - }; - await sendNotification(initNotification); - }); - test('should only return enabled tools in tools/list response', async () => { - const toolsRequest = { - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - }; - - const response = await sendJsonRpcMessage(toolsRequest); - - // Validate entire response structure with only 'add' tool - const validatedResponse = ToolsListResponseSchema.parse(response); - expect(validatedResponse).toEqual({ - jsonrpc: '2.0', - id: 2, - result: { - tools: [ - { - name: 'add', - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + await withMcpCommander(['--enabled-tools', 'add'], async (sendJsonRpcMessage, sendNotification) => { + // Initialize the connection + const initRequest = createInitializeRequest(); + await sendJsonRpcMessage(initRequest); + + const initNotification = createInitializedNotification(); + await sendNotification(initNotification); + + const toolsRequest = createToolsListRequest(); + + const response = await sendJsonRpcMessage(toolsRequest); + + // Validate entire response structure with only 'add' tool (filtering working correctly) + const validatedResponse = ToolsListResponseSchema.parse(response); + expect(validatedResponse).toEqual({ + jsonrpc: '2.0', + id: 2, + result: { + tools: [ + { + name: 'add', + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', }, - required: ['a', 'b'], - type: 'object', }, - }, - ], - }, + ], + }, + }); }); }); }); describe('disabled tools filtering', () => { - beforeAll(async () => { - // Start proxy with 'get-args' tool disabled - proxyProcess = Bun.spawn([ - controllerExecutable, - '--disabled-tools', 'get-args', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - - // Give the proxy time to start - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Initialize the connection - const initRequest = { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: { tools: {}, resources: {} }, - clientInfo: { name: 'test-client', version: '1.0.0' }, - }, - }; - await sendJsonRpcMessage(initRequest); - - const initNotification = { - jsonrpc: '2.0', - method: 'initialized', - }; - await sendNotification(initNotification); - }); - test('should exclude disabled tools from tools/list response', async () => { - const toolsRequest = { - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - }; - - const response = await sendJsonRpcMessage(toolsRequest); - - // Validate entire response structure with only 'add' tool (get-args disabled) - const validatedResponse = ToolsListResponseSchema.parse(response); - expect(validatedResponse).toEqual({ - jsonrpc: '2.0', - id: 2, - result: { - tools: [ - { - name: 'add', - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + await withMcpCommander(['--disabled-tools', 'get-args'], async (sendJsonRpcMessage, sendNotification) => { + // Initialize the connection + const initRequest = createInitializeRequest(); + await sendJsonRpcMessage(initRequest); + + const initNotification = createInitializedNotification(); + await sendNotification(initNotification); + + const toolsRequest = createToolsListRequest(); + + const response = await sendJsonRpcMessage(toolsRequest); + + // Validate entire response structure excluding 'get-args' tool (filtering working correctly) + const validatedResponse = ToolsListResponseSchema.parse(response); + expect(validatedResponse).toEqual({ + jsonrpc: '2.0', + id: 2, + result: { + tools: [ + { + name: 'add', + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', }, - required: ['a', 'b'], - type: 'object', }, - }, - ], - }, + { + name: 'subtract', + title: 'Subtraction Tool', + description: 'Subtract two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', + }, + }, + ], + }, + }); }); }); }); diff --git a/tests/test-messages.ts b/tests/test-messages.ts new file mode 100644 index 0000000..e67eb72 --- /dev/null +++ b/tests/test-messages.ts @@ -0,0 +1,108 @@ +import type { JsonRpcMessage } from './test-utils.js'; + +// Common JSON-RPC message templates used across tests + +// Initialize request with configurable ID and protocol version +export const createInitializeRequest = (id: number = 1, protocolVersion: string = '2025-06-18'): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion, + capabilities: { + tools: {}, + resources: {}, + }, + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, +}); + +// Initialized notification (no ID needed) +export const createInitializedNotification = (): JsonRpcMessage => ({ + jsonrpc: '2.0', + method: 'initialized', +}); + +// Tools list request with configurable ID +export const createToolsListRequest = (id: number = 2): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'tools/list', +}); + +// Tool call request with configurable parameters +export const createToolCallRequest = (id: number, toolName: string, args: Record = {}): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: toolName, + arguments: args, + }, +}); + +// Resources list request +export const createResourcesListRequest = (id: number = 4): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'resources/list', +}); + +// Resource read request +export const createResourceReadRequest = (id: number, uri: string): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'resources/read', + params: { + uri, + }, +}); + +// Resource templates list request +export const createResourceTemplatesListRequest = (id: number = 10): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'resources/templates/list', +}); + +// Ping request +export const createPingRequest = (id: number = 11): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'ping', +}); + +// Prompts list request +export const createPromptsListRequest = (id: number = 14): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'prompts/list', +}); + +// Prompt get request +export const createPromptGetRequest = (id: number, name: string, args: Record = {}): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'prompts/get', + params: { + name, + arguments: args, + }, +}); + +// Invalid method request for error testing +export const createInvalidMethodRequest = (id: number = 7): JsonRpcMessage => ({ + jsonrpc: '2.0', + id, + method: 'invalid/method', + params: {}, +}); + +// Common message sequences for initialization +export const initializeSequence = { + request: createInitializeRequest(), + notification: createInitializedNotification(), +}; \ No newline at end of file diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 0000000..67b5799 --- /dev/null +++ b/tests/test-utils.ts @@ -0,0 +1,102 @@ +import path from 'path'; + +type JsonRpcMessage = { + jsonrpc: string; + id?: number; + method: string; + params?: Record; +}; + +type JsonRpcResponse = { + jsonrpc: string; + id: number; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); +const controllerExecutable = path.resolve('./mcp-controller'); + +// Helper function to manage MCP Commander process lifecycle +export async function withMcpCommander( + args: string[], + callback: (sendJsonRpcMessage: (message: JsonRpcMessage) => Promise, sendNotification: (message: JsonRpcMessage) => Promise) => Promise +): Promise { + const proxyProcess = Bun.spawn([ + controllerExecutable, + ...args, + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + try { + // Give the proxy time to start + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Helper function to send JSON-RPC message and get response + async function sendJsonRpcMessage(message: JsonRpcMessage): Promise { + const messageStr = JSON.stringify(message) + '\n'; + + // Type guard to ensure stdin is available + if (!proxyProcess.stdin || typeof proxyProcess.stdin === 'number') { + throw new Error('Process stdin is not available'); + } + + // Write to stdin (FileSink in Bun) + proxyProcess.stdin.write(messageStr); + + // Type guard to ensure stdout is a ReadableStream + if (!proxyProcess.stdout || typeof proxyProcess.stdout === 'number') { + throw new Error('Process stdout is not available'); + } + + // Read response from stdout + const reader = proxyProcess.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + + if (value) { + const responseStr = new TextDecoder().decode(value); + const lines = responseStr.trim().split('\n'); + // Return the last JSON line (ignore any debug output) + for (let i = lines.length - 1; i >= 0; i--) { + try { + return JSON.parse(lines[i]) as JsonRpcResponse; + } catch { + continue; + } + } + } + + throw new Error('No valid JSON response received'); + } + + // Helper function to send notification (no response expected) + async function sendNotification(message: JsonRpcMessage): Promise { + const messageStr = JSON.stringify(message) + '\n'; + + if (!proxyProcess.stdin || typeof proxyProcess.stdin === 'number') { + throw new Error('Process stdin is not available'); + } + + proxyProcess.stdin.write(messageStr); + } + + return await callback(sendJsonRpcMessage, sendNotification); + } finally { + // Clean up - kill the proxy process + if (proxyProcess) { + proxyProcess.kill(); + await proxyProcess.exited; + } + } +} + +export type { JsonRpcMessage, JsonRpcResponse }; \ No newline at end of file diff --git a/tests/wildcard-filtering.test.ts b/tests/wildcard-filtering.test.ts index 0255ed7..3eedc8f 100644 --- a/tests/wildcard-filtering.test.ts +++ b/tests/wildcard-filtering.test.ts @@ -1,6 +1,8 @@ -import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; +import { test, expect, describe } from 'bun:test'; import path from 'path'; import { z } from 'zod'; +import { withMcpCommander } from './test-utils.js'; +import { createInitializeRequest, createToolsListRequest } from './test-messages.js'; const ToolsListResponseSchema = z.object({ jsonrpc: z.literal('2.0'), @@ -15,120 +17,32 @@ const ToolsListResponseSchema = z.object({ }); -type JsonRpcMessage = { - jsonrpc: '2.0'; - id: number; - method?: string; - params?: Record; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; -}; - -// Utility function to send JSON-RPC message and get response -async function sendJsonRpcMessage( - process: Bun.Subprocess, - message: JsonRpcMessage -): Promise { - (process.stdin as Bun.FileSink).write(JSON.stringify(message) + '\n'); - - const reader = (process.stdout as ReadableStream).getReader(); - let buffer = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) throw new Error('Stream ended without response'); - - if (value) { - buffer += new TextDecoder().decode(value); - const lines = buffer.split('\n'); - - for (const line of lines) { - if (line.trim()) { - try { - const response = JSON.parse(line.trim()) as JsonRpcMessage; - if (response.id === message.id) { - reader.releaseLock(); - return response; - } - } catch { - // Continue if line isn't valid JSON - continue; - } - } - } - } - } -} describe('Wildcard Tool Filtering Tests', () => { - let controllerProcess: Bun.Subprocess; - const fixtureServerPath = path.resolve(import.meta.dirname, 'fixtures', 'mcp-server.ts'); - - beforeAll(async () => { - // Start controller with wildcard patterns in proxy mode - controllerProcess = Bun.spawn([ - 'bun', 'run', 'src/cli.ts', - '--enabled-tools', 'get-*,add', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - }); - - afterAll(async () => { - if (controllerProcess) { - controllerProcess.kill(); - await controllerProcess.exited; - } - }); - test('should filter tools using wildcard patterns in proxy mode', async () => { - // Initialize the connection - const initializeRequest = { - jsonrpc: '2.0' as const, - id: 1, - method: 'initialize', - params: { - protocolVersion: '0.1.0', - capabilities: {}, - clientInfo: { - name: 'test-client', - version: '1.0.0', - }, - }, - }; + await withMcpCommander(['--enabled-tools', 'get-*,add'], async (sendJsonRpcMessage) => { + // Initialize the connection + const initializeRequest = createInitializeRequest(1, '0.1.0'); - const initResponse = await sendJsonRpcMessage(controllerProcess, initializeRequest); - expect(initResponse.error).toBeUndefined(); + const initResponse = await sendJsonRpcMessage(initializeRequest); + expect(initResponse.error).toBeUndefined(); - // Request tools list - const toolsRequest = { - jsonrpc: '2.0' as const, - id: 2, - method: 'tools/list', - params: {}, - }; + // Request tools list + const toolsRequest = createToolsListRequest(); - const response = await sendJsonRpcMessage(controllerProcess, toolsRequest); - const validatedResponse = ToolsListResponseSchema.parse(response); - - // Should only include tools matching get-* pattern and exact match 'add' - const toolNames = validatedResponse.result.tools.map(tool => tool.name); - - // Verify wildcard matching: should include get-args (matches get-*) - expect(toolNames).toContain('get-args'); - expect(toolNames).toContain('add'); - - // Should NOT include tools that don't match pattern - expect(toolNames).not.toContain('subtract'); + const response = await sendJsonRpcMessage(toolsRequest); + const validatedResponse = ToolsListResponseSchema.parse(response); + + // Should only include tools matching get-* pattern and exact match 'add' + const toolNames = validatedResponse.result.tools.map(tool => tool.name); + + // Verify wildcard matching: should include get-args (matches get-*) + expect(toolNames).toContain('get-args'); + expect(toolNames).toContain('add'); + + // Should NOT include tools that don't match pattern + expect(toolNames).not.toContain('subtract'); + }); }); });