# Directory Structure ``` ├── .eslintrc.json ├── .github │ ├── ISSUE_TEMPLATE.md │ └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CONTRIBUTING.md ├── examples │ └── basic-usage.js ├── guide │ ├── add_mcp.png │ └── result.png ├── jest.config.js ├── LICENSE ├── package.json ├── README.md ├── src │ ├── cli.ts │ ├── index.ts │ └── utils │ ├── buffer.test.ts │ ├── buffer.ts │ ├── command-runner.test.ts │ └── command-runner.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "semi": true, "trailingComma": "all", "singleQuote": true, "printWidth": 100, "tabWidth": 2, "endOfLine": "auto" } ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` # Source code src/ # Tests *.test.ts *.spec.ts test/ tests/ __tests__/ *.test.js *.spec.js coverage/ # Development files .github/ .vscode/ .editorconfig .eslintrc* .prettierrc* tsconfig.json jest.config.* *.tsbuildinfo # Examples examples/ # Git files .git/ .gitignore # CI/CD .github/ # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Environment variables .env* ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependency directories node_modules/ pnpm-lock.yaml # Built output dist/ build/ out/ # TypeScript cache *.tsbuildinfo # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Test coverage coverage/ # System files .DS_Store Thumbs.db ``` -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- ```json { "parser": "@typescript-eslint/parser", "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended" ], "plugins": ["@typescript-eslint"], "env": { "node": true, "es6": true }, "parserOptions": { "ecmaVersion": 2022, "sourceType": "module" }, "rules": { "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/no-explicit-any": "warn", "no-console": "off" } } ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Command Proxy An MCP (Model Context Protocol) server that acts as a proxy for CLI commands, specifically designed for Expo development but adaptable for any command-line application. ## How to use in Cursor (Expo example) 1. Go to the directory of your Expo project 2. Run `npx mcp-command-proxy --prefix "ExpoServer" --command "expo start" --port 8383` 3. Go to Cursor settings -> MCP -> +Add new MCP server, like this:  4. Set the name to "ExpoServer", Type to "SSE", URL to `http://localhost:8383/sse` 5. Click "Save" and you should now be able to use the MCP server in Cursor. Like this:  Recommended to use the `--port 8383` flag to avoid conflicts with other servers. Also, you can add following instruction to .cursorrules file: ``` You can use MCP getRecentLogs tool to get the most recent logs from Expo server. And if needed, you can send key presses to the running process using sendKeyPress tool. ``` ## Features - **Command Proxying**: Run any CLI command through the MCP server - **Log Collection**: Capture and store logs from running processes (configurable buffer size) - **Key Press Forwarding**: Forward key presses from client to the running process - **Transparent Experience**: The end user sees the command output exactly as if they ran it directly - **Interactive Commands**: Works with interactive CLI tools like Expo - **MCP Integration**: Built using the MCP SDK for easy integration with Claude and other MCP-enabled AI assistants ## How It Works 1. The server starts a specified command in a pseudo-terminal (PTY) 2. All stdout/stderr output is: - Streamed to the client in real-time - Stored in a circular buffer (configurable size, default 300 lines) 3. Key presses from the client are forwarded to the running process 4. The server provides tools to: - View collected logs - Send key presses to the process - Get the current state of the process ## Use Cases - **Expo Development**: Run `expo start` and interact with it while collecting logs - **Build Processes**: Monitor build processes and analyze logs - **Long-running Services**: Monitor services and keep recent log history - **Remote Command Execution**: Execute and monitor commands from remote clients ## Requirements - Node.js 18+ - TypeScript - pnpm (recommended) or npm ## Installation ```bash # Install dependencies pnpm install # Build the project pnpm build # Run directly pnpm start -- --prefix "MyServer" --command "expo start" # Or install globally pnpm install -g mcp-command-proxy --prefix "MyServer" --command "expo start" ``` ## Usage ### Basic Usage ```bash # Using the CLI mcp-command-proxy --prefix "ExpoServer" --command "expo start" # Or programmatically import { createServer } from 'mcp-command-proxy'; const server = await createServer({ prefix: 'ExpoServer', command: 'expo start', bufferSize: 500, port: 8080 }); // To stop the server later server.stop(); ``` ### Options - `--prefix, -p`: Name/prefix for the server (default: "CommandProxy") - `--command, -c`: Command to run (required) - `--buffer-size, -b`: Number of log lines to keep in memory (default: 300) - `--port`: Port for HTTP server (default: 8080) - `--help, -h`: Show help ### MCP Integration This server implements the following MCP tools: 1. `getRecentLogs`: Returns the most recent logs from the buffer - Parameters: - `limit` (optional): Number of logs to return (default: 100) - `types` (optional): Types of logs to include (stdout, stderr, system) (default: all) 2. `sendKeyPress`: Sends a key press to the running process - Parameters: - `key`: Key to send (e.g. "enter", "a", "1", "space") 3. `getProcessStatus`: Returns the current status of the process - Parameters: None ## Examples ### Running Expo Start ```bash mcp-command-proxy -p "ExpoServer" -c "expo start" -b 500 ``` ### Using with Claude 1. Configure Claude to connect to this MCP server (SSE endpoint: http://localhost:8080/sse) 2. Ask Claude to run Expo or other commands 3. Claude can analyze logs and help troubleshoot issues ## Development ```bash # Clone the repository git clone https://github.com/hormold/mcp-command-proxy.git cd mcp-command-proxy # Install dependencies pnpm install # Build the project pnpm build # Run in development mode pnpm dev ``` ## License MIT ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to MCP Command Proxy Thank you for your interest in contributing to MCP Command Proxy! This document provides guidelines and instructions for contributing. ## Code of Conduct Please be respectful and considerate of others when contributing to this project. ## How Can I Contribute? ### Reporting Bugs When reporting bugs, please include: - A clear and descriptive title - Steps to reproduce the issue - Expected behavior - Actual behavior - Screenshots if applicable - Your environment details (OS, Node.js version, etc.) ### Suggesting Enhancements Enhancement suggestions are welcome! Please provide: - A clear and descriptive title - A detailed description of the proposed enhancement - Any relevant examples or mock-ups ### Pull Requests 1. Fork the repository 2. Create a new branch for your feature or bug fix 3. Write your code, with tests if applicable 4. Ensure all tests pass 5. Submit a pull request with a clear description of the changes Please make sure your code follows our style guidelines: - Use TypeScript - Format code with Prettier - Follow ESLint rules - Write meaningful commit messages ## Development Setup ```bash # Clone your fork of the repo git clone https://github.com/YOUR_USERNAME/mcp-command-proxy.git # Navigate to the project directory cd mcp-command-proxy # Install dependencies pnpm install # Build the project pnpm build # Run tests pnpm test ``` ## Project Structure - `src/` - Source code - `index.ts` - Main entry point for library - `cli.ts` - CLI entry point - `utils/` - Utility functions - `command-runner.ts` - Core command running functionality - `buffer.ts` - Circular buffer implementation - `dist/` - Compiled JavaScript code ## License By contributing, you agree that your contributions will be licensed under the project's [MIT License](LICENSE). ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- ```markdown ## Description <!-- A clear and concise description of the issue --> ## Steps to Reproduce <!-- Steps to reproduce the behavior --> 1. 2. 3. ## Expected Behavior <!-- What you expected to happen --> ## Actual Behavior <!-- What actually happened --> ## Environment - OS: <!-- e.g. macOS, Windows, Linux --> - Node.js version: <!-- e.g. 18.0.0 --> - MCP Command Proxy version: <!-- e.g. 1.0.0 --> ## Additional Context <!-- Any other context, screenshots, or relevant information about the issue --> ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "outDir": "dist", "sourceMap": true, "declaration": true, "resolveJsonModule": true, "rootDir": "src", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] } ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/*.test.ts'], transform: { '^.+\\.tsx?$': [ 'ts-jest', { useESM: true, }, ], }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '^(\\.{1,2}/.*)\\.js$': '$1', }, collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.test.{ts,tsx}', ], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - name: Setup PNPM uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install - name: Run linter run: pnpm lint - name: Run tests run: pnpm test - name: Build run: pnpm build ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish to NPM on: release: types: [created] jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org/' - name: Setup PNPM uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install - name: Run tests run: pnpm test - name: Build run: pnpm build - name: Publish to NPM run: pnpm publish --no-git-checks env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} PNPM_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -------------------------------------------------------------------------------- /examples/basic-usage.js: -------------------------------------------------------------------------------- ```javascript /** * Basic usage example for mcp-command-proxy * * This example shows how to create an MCP server that runs the "echo" command * and demonstrates how to interact with it programmatically. * * @copyright 2025 Hormold * @license MIT */ import { createServer } from 'mcp-command-proxy'; // Create a server that runs "echo Hello, MCP!" const server = await createServer({ prefix: 'ExampleServer', command: 'echo "Hello, MCP!"', bufferSize: 100, port: 8080 }); console.log('MCP server started. Press Ctrl+C to exit.'); // Handle exit signals process.on('SIGINT', () => { console.log('Shutting down MCP server...'); server.stop(); process.exit(0); }); /** * In a real application, you might want to do something with the server * For example, you could expose it via a REST API or use it for automation * * Example of accessing logs programmatically: * * import { CommandRunner } from 'mcp-command-proxy'; * * // Create a command runner directly * const runner = new CommandRunner({ * command: 'npm start', * logBufferSize: 500 * }); * * // Handle logs * runner.on('log', (entry) => { * console.log(`[${entry.type}] ${entry.content}`); * }); * * // Start the command * runner.start(); * * // Send input * runner.write('y\n'); * * // Get logs * const logs = runner.getLogs(); * * // Stop the command * runner.stop(); */ ``` -------------------------------------------------------------------------------- /src/utils/buffer.ts: -------------------------------------------------------------------------------- ```typescript /** * A circular buffer implementation for storing log lines with a fixed capacity */ export class CircularBuffer<T> { private buffer: T[]; private head: number = 0; private tail: number = 0; private size: number = 0; private readonly capacity: number; /** * Create a new circular buffer * @param capacity Maximum number of items the buffer can hold */ constructor(capacity: number) { this.buffer = new Array<T>(capacity); this.capacity = capacity; } /** * Add an item to the buffer, overwriting the oldest item if full * @param item Item to add to the buffer */ push(item: T): void { this.buffer[this.head] = item; this.head = (this.head + 1) % this.capacity; if (this.size < this.capacity) { this.size++; } else { // Buffer is full, move tail forward this.tail = (this.tail + 1) % this.capacity; } } /** * Get all items currently in the buffer in order of insertion * @returns Array of items in order of insertion (oldest to newest) */ getAll(): T[] { const result: T[] = []; let current = this.tail; for (let i = 0; i < this.size; i++) { result.push(this.buffer[current]); current = (current + 1) % this.capacity; } return result; } /** * Get the number of items in the buffer */ getSize(): number { return this.size; } /** * Get the capacity of the buffer */ getCapacity(): number { return this.capacity; } /** * Clear all items from the buffer */ clear(): void { this.head = 0; this.tail = 0; this.size = 0; } } ``` -------------------------------------------------------------------------------- /src/utils/buffer.test.ts: -------------------------------------------------------------------------------- ```typescript import { CircularBuffer } from './buffer.js'; describe('CircularBuffer', () => { describe('constructor', () => { it('should create a buffer with the specified capacity', () => { const buffer = new CircularBuffer<number>(5); expect(buffer.getCapacity()).toBe(5); expect(buffer.getSize()).toBe(0); }); }); describe('push', () => { it('should add items to the buffer', () => { const buffer = new CircularBuffer<number>(3); buffer.push(1); buffer.push(2); expect(buffer.getSize()).toBe(2); expect(buffer.getAll()).toEqual([1, 2]); }); it('should overwrite old items when full', () => { const buffer = new CircularBuffer<number>(3); buffer.push(1); buffer.push(2); buffer.push(3); buffer.push(4); expect(buffer.getSize()).toBe(3); expect(buffer.getAll()).toEqual([2, 3, 4]); }); }); describe('getAll', () => { it('should return all items in order', () => { const buffer = new CircularBuffer<string>(5); buffer.push('a'); buffer.push('b'); buffer.push('c'); expect(buffer.getAll()).toEqual(['a', 'b', 'c']); }); it('should return an empty array for an empty buffer', () => { const buffer = new CircularBuffer<string>(5); expect(buffer.getAll()).toEqual([]); }); }); describe('clear', () => { it('should remove all items from the buffer', () => { const buffer = new CircularBuffer<number>(5); buffer.push(1); buffer.push(2); buffer.push(3); buffer.clear(); expect(buffer.getSize()).toBe(0); expect(buffer.getAll()).toEqual([]); }); }); }); ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-command-proxy", "version": "1.0.2", "description": "MCP server that proxies CLI commands and collects logs", "type": "module", "main": "dist/index.js", "bin": { "mcp-command-proxy": "dist/cli.js" }, "scripts": { "build": "tsc", "start": "node dist/cli.js", "dev": "ts-node --esm src/cli.ts", "test": "jest", "lint": "eslint . --ext .ts", "format": "prettier --write \"src/**/*.ts\"", "prepublishOnly": "npm run build", "prepare": "npm run build" }, "keywords": [ "mcp", "cli", "proxy", "logs", "expo", "model-context-protocol" ], "author": "Hormold", "repository": { "type": "git", "url": "https://github.com/hormold/mcp-command-proxy.git" }, "bugs": { "url": "https://github.com/hormold/mcp-command-proxy/issues" }, "homepage": "https://github.com/hormold/mcp-command-proxy#readme", "license": "MIT", "devDependencies": { "@types/express": "^5.0.0", "@types/jest": "^29.5.11", "@types/node": "^20.10.4", "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/parser": "^6.13.2", "eslint": "^8.55.0", "jest": "^29.7.0", "prettier": "^3.1.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "typescript": "^5.3.3" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", "express": "^4.21.2", "node-pty": "^1.0.0", "zod": "^3.24.2" }, "mcp": { "name": "Command Proxy", "description": "MCP server that proxies CLI commands and collects logs", "transportType": "sse", "sse": { "endpoint": "/sse", "messagesEndpoint": "/messages" }, "capabilities": { "resources": true, "tools": true }, "tools": [ { "name": "getRecentLogs", "description": "Get the most recent logs from the running command", "parameters": { "limit": "number?", "types": "string[]?" } }, { "name": "sendKeyPress", "description": "Send a key press to the running command", "parameters": { "key": "string" } }, { "name": "getProcessStatus", "description": "Get the current status of the running command", "parameters": {} } ], "resources": [ { "name": "server-info", "description": "Information about the server and available tools", "uri": "server://info" } ] }, "engines": { "node": ">=18.0.0" }, "files": [ "dist", "README.md", "LICENSE" ] } ``` -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { createServer } from './index.js'; // Parse command line arguments export function parseArgs(): { prefix: string; command: string; bufferSize: number; port: number } { const args = process.argv.slice(2); let prefix = 'CommandProxy'; let command = ''; let bufferSize = 300; let port = 8080; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--prefix' || arg === '-p') { prefix = args[++i] || prefix; } else if (arg === '--command' || arg === '-c') { command = args[++i] || command; } else if (arg === '--buffer-size' || arg === '-b') { bufferSize = parseInt(args[++i] || String(bufferSize), 10); } else if (arg === '--port') { port = parseInt(args[++i] || String(port), 10); } else if (arg === '--help' || arg === '-h') { showHelp(); process.exit(0); } } if (!command) { console.error('Error: Command is required'); showHelp(); process.exit(1); } return { prefix, command, bufferSize, port }; } export function showHelp(): void { console.log(` MCP Command Proxy - Run CLI commands with MCP Usage: mcp-command-proxy [options] Options: --prefix, -p Name/prefix for the server (default: "CommandProxy") --command, -c Command to run (required) --buffer-size, -b Number of log lines to keep in memory (default: 300) --port Port for HTTP server (default: 8080) --help, -h Show this help message Example: mcp-command-proxy -p "ExpoServer" -c "expo start" -b 500 --port 8080 `); } // Main function export async function main(): Promise<void> { try { const { prefix, command, bufferSize, port } = parseArgs(); console.log(`Starting MCP Command Proxy with: - Prefix: ${prefix} - Command: ${command} - Buffer Size: ${bufferSize} - Port: ${port} `); const server = await createServer({ prefix, command, bufferSize, port }); // Handle exit signals const exitHandler = (): void => { console.log('\nShutting down MCP Command Proxy...'); server.stop(); process.exit(0); }; process.on('SIGINT', exitHandler); process.on('SIGTERM', exitHandler); console.log(` MCP Command Proxy is running! - SSE endpoint: http://localhost:${port}/sse - Messages endpoint: http://localhost:${port}/messages Connect your MCP client to these endpoints. `); } catch (error) { console.error('Error starting MCP Command Proxy:', error); process.exit(1); } } // Only run main if this is the entry point main().catch(error => { console.error('Unhandled error:', error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/utils/command-runner.ts: -------------------------------------------------------------------------------- ```typescript import * as pty from 'node-pty'; import { EventEmitter } from 'events'; import { CircularBuffer } from './buffer.js'; /** * Log entry type for storing command output */ export interface LogEntry { timestamp: number; content: string; type: 'stdout' | 'stderr' | 'system'; } /** * Process status type */ export enum ProcessStatus { RUNNING = 'running', STOPPED = 'stopped', ERROR = 'error', } /** * Events emitted by the CommandRunner */ export interface CommandRunnerEvents { log: (entry: LogEntry) => void; exit: (code: number, signal?: string) => void; error: (error: Error) => void; statusChange: (status: ProcessStatus) => void; } /** * CommandRunner options */ export interface CommandRunnerOptions { command: string; args?: string[]; cwd?: string; env?: NodeJS.ProcessEnv; logBufferSize?: number; } /** * Class that runs a command in a pseudo-terminal and captures output */ export class CommandRunner extends EventEmitter { private process: pty.IPty | null = null; private logBuffer: CircularBuffer<LogEntry>; private status: ProcessStatus = ProcessStatus.STOPPED; private readonly command: string; private readonly args: string[]; private readonly cwd: string; private readonly env: NodeJS.ProcessEnv; /** * Create a new CommandRunner */ constructor(options: CommandRunnerOptions) { super(); // Parse command and arguments const parts = options.command.split(' '); this.command = parts[0]; this.args = parts.slice(1).concat(options.args || []); this.cwd = options.cwd || process.cwd(); this.env = options.env || process.env; this.logBuffer = new CircularBuffer<LogEntry>(options.logBufferSize || 300); } /** * Start the command process */ start(): void { try { // Add a system log entry this.addLogEntry(`Starting command: ${this.command} ${this.args.join(' ')}`, 'system'); // Spawn the process this.process = pty.spawn(this.command, this.args, { name: 'xterm-color', cols: 80, rows: 30, cwd: this.cwd, env: { ...this.env, FORCE_COLOR: '1', TERM: 'xterm-256color' }, handleFlowControl: true, }); // Set status to running this.setStatus(ProcessStatus.RUNNING); // Handle data events (output) this.process.onData((data) => { this.addLogEntry(data, 'stdout'); }); // Handle exit events this.process.onExit(({ exitCode, signal }) => { this.addLogEntry(`Process exited with code ${exitCode} and signal ${signal || 'none'}`, 'system'); this.setStatus(ProcessStatus.STOPPED); this.emit('exit', exitCode, signal); this.process = null; }); } catch (error) { this.setStatus(ProcessStatus.ERROR); this.addLogEntry(`Error starting command: ${error}`, 'system'); this.emit('error', error); } } /** * Stop the command process */ stop(): void { if (this.process && this.status === ProcessStatus.RUNNING) { this.addLogEntry('Stopping command...', 'system'); this.process.kill(); } } /** * Send data (key presses) to the process */ write(data: string): void { if (this.process && this.status === ProcessStatus.RUNNING) { this.addLogEntry(`Attempting to write key: ${JSON.stringify(data)}`, 'system'); try { this.process.write(data); this.addLogEntry(`Successfully wrote key`, 'system'); } catch (err) { this.addLogEntry(`Failed to write key: ${err}`, 'system'); } } else { this.addLogEntry('Cannot write to process: not running', 'system'); } } /** * Get all log entries */ getLogs(): LogEntry[] { return this.logBuffer.getAll(); } /** * Get current process status */ getStatus(): ProcessStatus { return this.status; } /** * Add a log entry to the buffer and emit a log event */ private addLogEntry(content: string, type: LogEntry['type']): void { const entry: LogEntry = { timestamp: Date.now(), content, type, }; this.logBuffer.push(entry); this.emit('log', entry); } /** * Set the process status and emit a status change event */ private setStatus(status: ProcessStatus): void { this.status = status; this.emit('statusChange', this.status); } } ``` -------------------------------------------------------------------------------- /src/utils/command-runner.test.ts: -------------------------------------------------------------------------------- ```typescript import { CommandRunner, ProcessStatus } from './command-runner'; import * as pty from 'node-pty'; // Mock node-pty jest.mock('node-pty', () => ({ spawn: jest.fn(() => ({ onData: jest.fn(), onExit: jest.fn(), write: jest.fn(), kill: jest.fn(), })), })); describe('CommandRunner', () => { let runner: CommandRunner; let mockProcess: jest.Mocked<pty.IPty>; beforeEach(() => { jest.clearAllMocks(); mockProcess = { onData: jest.fn(), onExit: jest.fn(), write: jest.fn(), kill: jest.fn(), } as unknown as jest.Mocked<pty.IPty>; (pty.spawn as jest.Mock).mockReturnValue(mockProcess); }); describe('constructor', () => { it('should initialize with default options', () => { runner = new CommandRunner({ command: 'test-cmd' }); expect(runner.getStatus()).toBe(ProcessStatus.STOPPED); expect(runner.getLogs()).toEqual([]); }); it('should parse command and arguments correctly', () => { runner = new CommandRunner({ command: 'test-cmd arg1 arg2', args: ['arg3'] }); runner.start(); expect(pty.spawn).toHaveBeenCalledWith( 'test-cmd', ['arg1', 'arg2', 'arg3'], expect.any(Object) ); }); it('should use provided options', () => { const cwd = '/test/dir'; const env = { TEST_ENV: 'value' }; runner = new CommandRunner({ command: 'test-cmd', cwd, env, logBufferSize: 100 }); runner.start(); expect(pty.spawn).toHaveBeenCalledWith( 'test-cmd', [], expect.objectContaining({ cwd, env: expect.objectContaining(env) }) ); }); }); describe('process lifecycle', () => { beforeEach(() => { runner = new CommandRunner({ command: 'test-cmd' }); }); it('should start process and update status', () => { const statusListener = jest.fn(); runner.on('statusChange', statusListener); runner.start(); expect(runner.getStatus()).toBe(ProcessStatus.RUNNING); expect(statusListener).toHaveBeenCalledWith(ProcessStatus.RUNNING); expect(pty.spawn).toHaveBeenCalled(); }); it('should handle process exit', () => { const exitListener = jest.fn(); const statusListener = jest.fn(); runner.on('exit', exitListener); runner.on('statusChange', statusListener); runner.start(); const exitCallback = (mockProcess.onExit as jest.Mock).mock.calls[0][0]; exitCallback({ exitCode: 0, signal: null }); expect(runner.getStatus()).toBe(ProcessStatus.STOPPED); expect(exitListener).toHaveBeenCalledWith(0, null); expect(statusListener).toHaveBeenCalledWith(ProcessStatus.STOPPED); }); it('should stop process', () => { runner.start(); runner.stop(); expect(mockProcess.kill).toHaveBeenCalled(); }); }); describe('log management', () => { beforeEach(() => { runner = new CommandRunner({ command: 'test-cmd' }); }); it('should capture stdout', () => { const logListener = jest.fn(); runner.on('log', logListener); runner.start(); const dataCallback = (mockProcess.onData as jest.Mock).mock.calls[0][0]; dataCallback('test output'); const logs = runner.getLogs(); expect(logs).toHaveLength(2); // Including start command log expect(logs[1].content).toBe('test output'); expect(logs[1].type).toBe('stdout'); expect(logListener).toHaveBeenCalledWith(expect.objectContaining({ content: 'test output', type: 'stdout' })); }); it('should respect log buffer size', () => { runner = new CommandRunner({ command: 'test-cmd', logBufferSize: 2 }); runner.start(); const dataCallback = (mockProcess.onData as jest.Mock).mock.calls[0][0]; dataCallback('output1'); dataCallback('output2'); dataCallback('output3'); const logs = runner.getLogs(); expect(logs).toHaveLength(2); expect(logs.map(l => l.content)).toEqual(['output2', 'output3']); }); }); describe('error handling', () => { it('should handle spawn errors', () => { const error = new Error('Spawn error'); (pty.spawn as jest.Mock).mockImplementation(() => { throw error; }); const errorListener = jest.fn(); runner = new CommandRunner({ command: 'test-cmd' }); runner.on('error', errorListener); runner.start(); expect(runner.getStatus()).toBe(ProcessStatus.ERROR); expect(errorListener).toHaveBeenCalledWith(error); }); it('should handle write errors', () => { runner = new CommandRunner({ command: 'test-cmd' }); runner.start(); mockProcess.write.mockImplementation(() => { throw new Error('Write error'); }); runner.write('test'); const logs = runner.getLogs(); expect(logs.some(log => log.type === 'system' && log.content.includes('Failed to write key') )).toBe(true); }); }); describe('write', () => { beforeEach(() => { runner = new CommandRunner({ command: 'test-cmd' }); }); it('should write to process when running', () => { runner.start(); runner.write('test input'); expect(mockProcess.write).toHaveBeenCalledWith('test input'); }); it('should not write when process is stopped', () => { runner.write('test input'); expect(mockProcess.write).not.toHaveBeenCalled(); const logs = runner.getLogs(); expect(logs.some(log => log.type === 'system' && log.content.includes('Cannot write to process: not running') )).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript /** * MCP Command Proxy * * A Model Context Protocol (MCP) server for proxying CLI commands and collecting logs * * @module mcp-command-proxy */ import express from 'express'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { z } from 'zod'; import { CommandRunner, ProcessStatus, LogEntry } from './utils/command-runner.js'; /** * Create an MCP server for proxying CLI commands */ export async function createServer(options: { prefix: string; command: string; bufferSize?: number; port: number; }) { const { prefix, command, bufferSize = 300, port } = options; // Create Express app const app = express(); // Parse JSON bodies app.use(express.json()); // Create MCP server const server = new McpServer({ name: `${prefix} MCP Server`, version: '1.0.0' }); // Create command runner const commandRunner = new CommandRunner({ command, logBufferSize: bufferSize, }); // Setup command runner event handlers commandRunner.on('log', (entry: LogEntry) => { // Log to console for debugging if (entry.type === 'stdout') { process.stdout.write(entry.content); } else if (entry.type === 'stderr') { process.stderr.write(entry.content); } else { console.log(`[${prefix}] ${entry.content}`); } }); commandRunner.on('exit', (code: number) => { console.log(`[${prefix}] Command exited with code ${code}`); }); commandRunner.on('error', (error: Error) => { console.error(`[${prefix}] Command error:`, error); }); // Add MCP tools // Add a resource for recent logs server.resource( 'logs', 'logs://recent', async () => { const logs = commandRunner.getLogs() .slice(-100); // Default to 100 most recent logs return { contents: [{ uri: 'logs://recent', text: JSON.stringify(logs, null, 2) }] }; } ); // Add tool to get recent logs server.tool( 'getRecentLogs', { limit: z.number().optional().default(100), types: z.array(z.enum(['stdout', 'stderr', 'system'])).optional().default(['stdout', 'stderr', 'system']) }, async ({ limit, types }) => { const logs = commandRunner.getLogs() .filter((log: LogEntry) => types.includes(log.type)) .slice(-limit); return { content: [ { type: 'text', text: JSON.stringify(logs) } ] }; } ); // Add tool to send key press server.tool( 'sendKeyPress', { key: z.string() }, async ({ key }) => { if (commandRunner.getStatus() !== ProcessStatus.RUNNING) { return { content: [ { type: 'text', text: 'Command is not running' } ], isError: true }; } // Convert special key names to actual characters if needed const keyMap: Record<string, string> = { 'enter': '\r', 'return': '\r', 'space': ' ', 'tab': '\t', 'escape': '\x1b', 'backspace': '\x7f' }; const keyToSend = keyMap[key.toLowerCase()] || key; commandRunner.write(keyToSend); return { content: [ { type: 'text', text: 'Key sent successfully' } ] }; } ); // Add tool to get process status server.tool( 'getProcessStatus', {}, async () => { const status = commandRunner.getStatus(); return { content: [ { type: 'text', text: JSON.stringify({ status }) } ] }; } ); let transport: SSEServerTransport; // Set up SSE endpoint app.get("/sse", async (req, res) => { console.log(`[${prefix}] SSE endpoint connected`); if(!transport) { transport = new SSEServerTransport("/messages", res); } await server.connect(transport); }); app.post("/messages", async (req, res) => { await transport.handlePostMessage(req, res); console.log(`[${prefix}] Message received:`, req.body); }); // Setup raw mode for stdin if (process.stdin.isTTY) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); console.log(`[${prefix}] Terminal is in TTY mode, listening for keypresses`); process.stdin.on('data', (data: Buffer | string) => { // Convert buffer to string if needed const str = Buffer.isBuffer(data) ? data.toString() : data; console.log(`[${prefix}] Received keypress:`, str.split('').map((c: string) => c.charCodeAt(0))); // Handle special keys if (str === '\u0003') { // Ctrl+C console.log(`[${prefix}] Received Ctrl+C, exiting...`); process.exit(); } // Map some common keys const keyMap: Record<string, string> = { '\u001b[A': '\x1b[A', // Up arrow '\u001b[B': '\x1b[B', // Down arrow '\u001b[C': '\x1b[C', // Right arrow '\u001b[D': '\x1b[D', // Left arrow '\r': '\r\n', // Enter }; // Forward keypress to the child process if (commandRunner.getStatus() === ProcessStatus.RUNNING) { const mapped = keyMap[str] || str; console.log(`[${prefix}] Forwarding keypress to child process:`, mapped.split('').map((c: string) => c.charCodeAt(0))); commandRunner.write(mapped); } }); // Handle process exit process.on('exit', () => { if (process.stdin.isTTY) { process.stdin.setRawMode(false); } }); } else { console.log(`[${prefix}] Terminal is not in TTY mode, keypresses won't be captured`); } // Start the command commandRunner.start(); // Start the HTTP server const server_instance = app.listen(port, () => { console.log(`[${prefix}] MCP server listening on port ${port}`); console.log(`[${prefix}] SSE endpoint: http://localhost:${port}/sse`); console.log(`[${prefix}] Messages endpoint: http://localhost:${port}/messages`); console.log(`[${prefix}] MCP server started with command: ${command}`); }); // Return a stop function return { stop: () => { commandRunner.stop(); server_instance.close(); console.log(`[${prefix}] MCP server stopped`); } }; } // Re-export other utilities export { CommandRunner, ProcessStatus, type LogEntry } from './utils/command-runner.js'; export { CircularBuffer } from './utils/buffer.js'; // Re-export the CLI for direct execution export * from './cli.js'; ```