diff --git a/README.md b/README.md index d417c24..c0f3230 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ bun run build # Proxy to a local MCP server ./mcp-controller bun run my-server.ts +# Proxy to an npm-distributed MCP server +./mcp-controller @modelcontextprotocol/server-sequential-thinking + # Proxy to a Python MCP server ./mcp-controller python -m my_mcp_server diff --git a/docs/bunx-elimination-design.md b/docs/bunx-elimination-design.md new file mode 100644 index 0000000..c1fc936 --- /dev/null +++ b/docs/bunx-elimination-design.md @@ -0,0 +1,86 @@ +# Eliminating Redundant bunx Usage with BUN_BE_BUN + +## Problem + +Currently, using this MCP controller with MCP servers requires redundant `bunx` calls: + +```bash +bunx mcp-controller bunx @some/mcp-server # Redundant bunx +``` + +This is clunky and unnecessary since many MCP servers are npm packages that work with `bunx`. + +## Solution + +Use Bun's `BUN_BE_BUN=1` environment variable to make our compiled executable behave like `bun x` (bunx). + +## How It Works + +When `BUN_BE_BUN=1` is set, a compiled Bun executable ignores its bundled code and acts like the `bun` CLI. Combined with the `x` command, this gives us `bunx` functionality. + +**User runs:** +```bash +bunx mcp-controller some-mcp-server +``` + +**We internally execute:** +```bash +BUN_BE_BUN=1 ./mcp-controller x some-mcp-server +``` + +**Which behaves like:** +```bash +bunx some-mcp-server +``` + +## Implementation + +Change `src/target-server.ts` line 11-17 from: + +```typescript +const [command, ...args] = config.targetCommand; +const process = Bun.spawn([command, ...args], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', +}); +``` + +To: + +```typescript +const process = Bun.spawn([process.execPath, "x", ...config.targetCommand], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', + env: { + ...process.env, + BUN_BE_BUN: "1" + } +}); +``` + +## Benefits + +1. **Clean UX**: `bunx mcp-controller server-name` instead of `bunx mcp-controller bunx server-name` +2. **Universal**: Works for npm packages, Docker commands, local scripts - anything `bunx` supports +3. **Fast**: Leverages Bun's global caching and 100x speed improvement over npx +4. **Simple**: Single line change, leverages existing Bun functionality + +## Usage Examples + +```bash +# npm packages +bunx mcp-controller @some/mcp-server + +# Docker +bunx mcp-controller docker run --rm some-image + +# Local scripts +bunx mcp-controller ./local-server.ts + +# Any CLI tool +bunx mcp-controller python server.py +``` + +All work exactly like `bunx` would, but proxied through our controller. \ No newline at end of file diff --git a/package.json b/package.json index a8b415d..80a8b76 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcp-controller", "version": "0.2.0", - "description": "MCP server proxy that forwards JSON RPC communication between MCP clients and target servers", + "description": "MCP server proxy that enables controlling availability of tools.", "type": "module", "bin": { "mcp-controller": "mcp-controller" @@ -24,12 +24,9 @@ "test:watch": "bun test tests/ --watch", "prepublish": "bun test && bun run build" }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "zod": "^3.23.8" - }, "devDependencies": { "@eslint/js": "^9.15.0", + "@modelcontextprotocol/sdk": "^1.0.0", "@types/node": "^22.9.0", "bun-types": "^1.1.34", "eslint": "^9.15.0", @@ -37,13 +34,8 @@ "globals": "^15.12.0", "prettier": "^3.3.3", "typescript": "^5.6.3", - "typescript-eslint": "^8.14.0" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "engines": { - "node": ">=18.0.0" + "typescript-eslint": "^8.14.0", + "zod": "^3.23.8" }, "keywords": [ "mcp", @@ -52,6 +44,6 @@ "json-rpc", "cli" ], - "author": "", + "author": "@eli0shin", "license": "MIT" } diff --git a/src/target-server.ts b/src/target-server.ts index 665a753..8ebc6e1 100644 --- a/src/target-server.ts +++ b/src/target-server.ts @@ -10,17 +10,24 @@ export class TargetServerManager { const [command, ...args] = config.targetCommand; - const process = Bun.spawn([command, ...args], { + // Strip bunx/npx prefixes to avoid redundant calls + const targetCommand = (command === 'bunx' || command === 'npx') ? args : [command, ...args]; + + const subprocess = Bun.spawn([process.execPath, "x", ...targetCommand], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', + env: { + ...process.env, + BUN_BE_BUN: "1" + } }); - const stdin = process.stdin; - const stdout = process.stdout; + const stdin = subprocess.stdin; + const stdout = subprocess.stdout; this.targetServer = { - process, + process: subprocess, stdin, stdout, }; diff --git a/tests/bunx-integration.test.ts b/tests/bunx-integration.test.ts new file mode 100644 index 0000000..18c37a2 --- /dev/null +++ b/tests/bunx-integration.test.ts @@ -0,0 +1,93 @@ +import { test, expect, describe } from 'bun:test'; +import path from 'path'; +import { JSONRPCResponseSchema, InitializeResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { createInitializeRequest } from './test-messages.js'; + +// Complete response schema for initialize using MCP SDK types +const InitializeResponseSchema = JSONRPCResponseSchema.extend({ + result: InitializeResultSchema +}); + +const controllerExecutable = path.resolve('./mcp-controller'); + +describe('Bunx Integration Tests', () => { + test('should work with npm package @modelcontextprotocol/server-sequential-thinking', async () => { + // Test that our BUN_BE_BUN implementation works with real npm packages + const proxyProcess = Bun.spawn([ + controllerExecutable, + '@modelcontextprotocol/server-sequential-thinking' + ], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + try { + // Give the proxy time to start and install the npm package + await new Promise(resolve => setTimeout(resolve, 3000)); + + const request = createInitializeRequest(); + const messageStr = JSON.stringify(request) + '\n'; + + // Send initialize request + if (!proxyProcess.stdin || typeof proxyProcess.stdin === 'number') { + throw new Error('Process stdin is not available'); + } + proxyProcess.stdin.write(messageStr); + + // Read response + if (!proxyProcess.stdout || typeof proxyProcess.stdout === 'number') { + throw new Error('Process stdout is not available'); + } + + const reader = proxyProcess.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + + if (!value) { + throw new Error('No response received'); + } + + const responseStr = new TextDecoder().decode(value); + const lines = responseStr.trim().split('\n'); + + // Find valid JSON response + let response; + for (let i = lines.length - 1; i >= 0; i--) { + try { + response = JSON.parse(lines[i]); + break; + } catch { + continue; + } + } + + if (!response) { + throw new Error('No valid JSON response received'); + } + + // Validate response structure + const validatedResponse = InitializeResponseSchema.parse(response); + + expect(validatedResponse).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2024-11-05', + serverInfo: { + name: 'sequential-thinking-server', + version: '0.2.0', + }, + capabilities: { + tools: {}, + }, + }, + }); + } finally { + if (proxyProcess) { + proxyProcess.kill(); + await proxyProcess.exited; + } + } + }, 15000); +}); \ No newline at end of file