diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8e1ed6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Type check + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Run tests + run: bun run test + + - name: Build executable + run: bun run build + + - name: Verify executable + run: | + ls -la mcp-controller + # Test that executable runs and shows usage (exits with error code 1 when no args) + ./mcp-controller || [ $? -eq 1 ] \ No newline at end of file diff --git a/package.json b/package.json index ccb2a19..35a5a61 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "bun build src/cli.ts --compile --outfile mcp-controller", "dev": "bun run src/cli.ts", - "typecheck": "bun --bun tsc --noEmit", + "typecheck": "bun tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --check .", diff --git a/src/cli.ts b/src/cli.ts index 97e2bde..808ceb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,18 +1,81 @@ #!/usr/bin/env bun import { McpProxyServer } from './proxy-server.js'; -import type { ProxyConfig } from './types.js'; +import type { ProxyConfig, Tool } from './types.js'; +import { TargetServerManager } from './target-server.js'; + +function parseListToolsArguments(args: string[]): ProxyConfig { + if (args.length === 0) { + process.stderr.write('Error: No target command specified for list-tools\n'); + process.exit(1); + } + + let enabledTools: string[] | undefined; + let disabledTools: string[] | undefined; + const targetCommand: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--enabled-tools') { + if (i + 1 >= args.length) { + process.stderr.write('Error: --enabled-tools requires a value\n'); + process.exit(1); + } + if (disabledTools !== undefined) { + process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.exit(1); + } + enabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + i++; // Skip the value argument + } else if (arg === '--disabled-tools') { + if (i + 1 >= args.length) { + process.stderr.write('Error: --disabled-tools requires a value\n'); + process.exit(1); + } + if (enabledTools !== undefined) { + process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.exit(1); + } + disabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + i++; // Skip the value argument + } else { + targetCommand.push(arg); + } + } + + if (targetCommand.length === 0) { + process.stderr.write('Error: No target command specified for list-tools\n'); + process.exit(1); + } + + return { + targetCommand, + enabledTools, + disabledTools, + serverName: 'mcp-controller', + serverVersion: '0.1.0', + mode: 'list-tools', + }; +} function parseArguments(): ProxyConfig { const args = process.argv.slice(2); 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('Example: mcp-controller --enabled-tools add,subtract 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); } + // Check if first argument is list-tools + if (args[0] === 'list-tools') { + return parseListToolsArguments(args.slice(1)); + } + let enabledTools: string[] | undefined; let disabledTools: string[] | undefined; const targetCommand: string[] = []; @@ -58,13 +121,128 @@ function parseArguments(): ProxyConfig { disabledTools, serverName: 'mcp-controller', serverVersion: '0.1.0', + mode: 'proxy', }; } +async function listTools(config: ProxyConfig): Promise { + const targetManager = new TargetServerManager(); + let targetServer; + + try { + // Start the target server + targetServer = await targetManager.startTargetServer(config); + + // Send initialize request + const initializeRequest = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '0.1.0', + capabilities: {}, + clientInfo: { + name: config.serverName, + version: config.serverVersion, + }, + }, + }; + + targetServer.stdin.write(JSON.stringify(initializeRequest) + '\n'); + + // Wait for initialize response + const reader = targetServer.stdout.getReader(); + let buffer = ''; + + // Read initialize response + const { value: initValue } = await reader.read(); + if (!initValue) throw new Error('No response from server'); + + buffer += new TextDecoder().decode(initValue); + const initLines = buffer.split('\n'); + const initResponse = initLines.find(line => line.trim()); + if (!initResponse) throw new Error('No valid response received'); + + const parsedInitResponse = JSON.parse(initResponse); + if (parsedInitResponse.error) { + throw new Error(`Initialize failed: ${parsedInitResponse.error.message}`); + } + + // Send tools/list request + const toolsListRequest = { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }; + + targetServer.stdin.write(JSON.stringify(toolsListRequest) + '\n'); + + // Read tools/list response + let toolsBuffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + if (value) { + toolsBuffer += new TextDecoder().decode(value); + const lines = toolsBuffer.split('\n'); + + for (const line of lines) { + if (line.trim()) { + try { + const response = JSON.parse(line.trim()); + if (response.id === 2) { + if (response.error) { + throw new Error(`Tools list failed: ${response.error.message}`); + } + + // Apply filtering and display tools + let tools = response.result.tools || []; + + if (config.enabledTools) { + tools = tools.filter((tool: Tool) => config.enabledTools!.includes(tool.name)); + } else if (config.disabledTools) { + tools = tools.filter((tool: Tool) => !config.disabledTools!.includes(tool.name)); + } + + // Print tools in the requested format + for (const tool of tools) { + process.stdout.write(`${tool.name}: ${tool.description || 'No description available'}\n`); + } + + return; // Exit successfully + } + } catch { + // Continue reading if this line wasn't valid JSON + continue; + } + } + } + } + } + + throw new Error('No tools/list response received'); + + } catch (error) { + process.stderr.write(`Error listing tools: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } finally { + if (targetManager) { + await targetManager.stopTargetServer(); + } + } +} + async function main(): Promise { try { const config = parseArguments(); + if (config.mode === 'list-tools') { + await listTools(config); + return; + } + const proxyServer = new McpProxyServer(config); // Handle graceful shutdown diff --git a/src/types.ts b/src/types.ts index 94b64da..86b3f71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export type ProxyConfig = { disabledTools?: string[]; serverName: string; serverVersion: string; + mode?: 'proxy' | 'list-tools'; }; export type TargetServerProcess = { diff --git a/tests/list-tools.test.ts b/tests/list-tools.test.ts new file mode 100644 index 0000000..26a1621 --- /dev/null +++ b/tests/list-tools.test.ts @@ -0,0 +1,196 @@ +import { test, expect, describe } from 'bun:test'; +import path from 'path'; + +describe('List Tools Command Tests', () => { + const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); + const controllerExecutable = path.resolve('./mcp-controller'); + + test('should list all available tools', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + await process.exited; + + // Should not have errors + expect(errorOutput.trim()).toBe(''); + + // Should list both tools in the expected format + const lines = output.trim().split('\n'); + expect(lines).toEqual([ + 'add: Add two numbers', + 'get-args: Returns the command line arguments passed to the server' + ]); + }); + + test('should list only enabled tools when --enabled-tools is specified', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + '--enabled-tools', 'add', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + await process.exited; + + // Should not have errors + expect(errorOutput.trim()).toBe(''); + + // Should only list the enabled tool + const lines = output.trim().split('\n'); + expect(lines).toEqual([ + 'add: Add two numbers' + ]); + }); + + test('should exclude disabled tools when --disabled-tools is specified', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + '--disabled-tools', 'get-args', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + await process.exited; + + // Should not have errors + expect(errorOutput.trim()).toBe(''); + + // Should only list the non-disabled tool + const lines = output.trim().split('\n'); + expect(lines).toEqual([ + 'add: Add two numbers' + ]); + }); + + test('should show error when no target command specified', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools' + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + const exitCode = await process.exited; + + // Should exit with error code + expect(exitCode).toBe(1); + + // Should have error message + expect(errorOutput.trim()).toBe('Error: No target command specified for list-tools'); + + // Should have no stdout output + expect(output.trim()).toBe(''); + }); + + test('should handle server initialization errors', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + 'nonexistent-command' + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + const exitCode = await process.exited; + + // Should exit with error code + expect(exitCode).toBe(1); + + // Should have error message about listing tools + expect(errorOutput).toContain('Error listing tools:'); + + // Should have no stdout output + expect(output.trim()).toBe(''); + }); + + test('should handle mutually exclusive enabled/disabled tools arguments', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + '--enabled-tools', 'add', + '--disabled-tools', 'get-args', + 'bun', 'run', fixtureServerPath + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + const exitCode = await process.exited; + + // Should exit with error code + expect(exitCode).toBe(1); + + // Should have error message about mutual exclusivity + expect(errorOutput.trim()).toBe('Error: --enabled-tools and --disabled-tools are mutually exclusive'); + + // Should have no stdout output + expect(output.trim()).toBe(''); + }); + + test('should pass command line arguments to target server during list-tools', async () => { + const process = Bun.spawn([ + controllerExecutable, + 'list-tools', + 'bun', 'run', fixtureServerPath, + 'pos-arg-1', 'pos-arg-2', + '--named-arg', 'named-value' + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + const output = await new Response(process.stdout).text(); + const errorOutput = await new Response(process.stderr).text(); + + await process.exited; + + // Should not have errors + expect(errorOutput.trim()).toBe(''); + + // Should list tools normally (arguments don't affect tool listing) + const lines = output.trim().split('\n'); + expect(lines).toEqual([ + 'add: Add two numbers', + 'get-args: Returns the command line arguments passed to the server' + ]); + }); +}); \ No newline at end of file