├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── FUNDING.yml ├── workflows │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude.yml │ └── release.yml └── SECURITY.md ├── .prettierignore ├── banner.png ├── src ├── types │ └── socket-activation.d.ts ├── daemon │ ├── index.ts │ ├── mcp-client-utils.ts │ ├── client.ts │ ├── runtime.ts │ └── commands.ts ├── utils │ └── safety.ts └── config.ts ├── .prettierrc.js ├── tsconfig.test.json ├── tsconfig.json ├── .gitignore ├── vitest.config.ts ├── tsup.config.ts ├── CHANGELOG.md ├── LICENSE ├── tests ├── unit │ ├── timeout-utils.test.ts │ ├── daemon-client-aborted-signal.test.ts │ ├── runtime.identity.test.ts │ ├── daemon-client-midflight-cancel.test.ts │ ├── ipc.limits.test.ts │ ├── safety.test.ts │ ├── mcp-client-timeout.test.ts │ ├── daemon-client-connect-retry.test.ts │ └── daemon-client-ipc-timeout.test.ts ├── integration │ ├── test-helper.test.ts │ ├── ipc-communication.test.ts │ └── daemon-lifecycle.test.ts ├── e2e │ └── cli.test.ts └── test-helper.ts ├── package.json ├── AGENTS.md ├── timeout-test-server.js ├── security.md ├── complex-test-server.js ├── test-server.js ├── eslint.config.js ├── CLAUDE.md ├── CONTRIBUTING.md ├── scripts ├── test-regression.sh └── release.sh ├── weather-server.js ├── README.md └── docs └── architecture.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | *.log 5 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameroncooke/mcpli/HEAD/banner.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: cameroncooke 3 | buy_me_a_coffee: cameroncooke 4 | -------------------------------------------------------------------------------- /src/types/socket-activation.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'socket-activation' { 2 | export function collect(socketName: string): number[]; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 100, 6 | tabWidth: 2, 7 | endOfLine: 'auto', 8 | }; -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals", "node"], 5 | "allowJs": true, 6 | "noEmit": true 7 | }, 8 | "include": ["src/**/*.test.ts", "tests/**/*"], 9 | "exclude": ["node_modules", "dist"] 10 | } -------------------------------------------------------------------------------- /src/daemon/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Public daemon system API exports. 3 | * 4 | * Consumers embedding MCPLI as a library can import from this barrel to access 5 | * daemon orchestration, IPC, and client helpers without reaching into 6 | * individual file paths. 7 | */ 8 | // Daemon management exports 9 | 10 | export * from './ipc.ts'; 11 | export * from './client.ts'; 12 | export * from './commands.ts'; 13 | export * from './runtime.ts'; 14 | export * from './runtime-launchd.ts'; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "sourceMap": true, 13 | "inlineSources": true, 14 | 15 | // Set `sourceRoot` to "/" to strip the build path prefix 16 | // from generated source code references. 17 | // This improves issue grouping in Sentry. 18 | "sourceRoot": "/", 19 | "allowImportingTsExtensions": true, 20 | "noEmit": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": [ 24 | "node_modules", 25 | "**/*.test.ts", 26 | "tests/**/*" 27 | ] 28 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Logs 8 | *.log 9 | logs/ 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage/ 18 | 19 | # IDE files 20 | .vscode/ 21 | .idea/ 22 | 23 | # OS generated files 24 | .DS_Store 25 | .DS_Store? 26 | ._* 27 | .Spotlight-V100 28 | .Trashes 29 | ehthumbs.db 30 | Thumbs.db 31 | 32 | # Temporary files 33 | *.tmp 34 | *.temp 35 | 36 | # Environment files 37 | .env 38 | .env.local 39 | .env.development.local 40 | .env.test.local 41 | .env.production.local 42 | 43 | # MCPLI 44 | .mcpli/ 45 | 46 | # Repomix output files 47 | *repomix* 48 | 49 | # Local Claude settings 50 | .claude/settings.local.json 51 | 52 | # Debug/temporary files 53 | debug-*.js -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | globals: true, 7 | include: ['tests/**/*.test.ts', 'src/**/*.test.ts'], 8 | exclude: ['dist/**', 'node_modules/**'], 9 | testTimeout: 60000, 10 | hookTimeout: 60000, 11 | reporters: 'default', 12 | // Allow parallelism by default; can be disabled with MCPLI_TEST_SERIAL=1 13 | threads: process.env.MCPLI_TEST_SERIAL === '1' ? false : true, 14 | fileParallelism: process.env.MCPLI_TEST_SERIAL === '1' ? false : true, 15 | // Use forked workers for better process isolation when parallel 16 | pool: process.env.MCPLI_TEST_SERIAL === '1' ? 'threads' : 'forks', 17 | coverage: { 18 | reporter: ['text', 'html', 'lcov'], 19 | provider: 'v8' 20 | } 21 | }, 22 | esbuild: { 23 | target: 'node22' 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import { chmodSync, existsSync, mkdirSync, copyFileSync } from 'fs'; 3 | 4 | export default defineConfig({ 5 | entry: { 6 | mcpli: 'src/mcpli.ts', 7 | 'daemon/wrapper': 'src/daemon/wrapper.ts', 8 | }, 9 | format: ['esm'], 10 | target: 'node22', 11 | platform: 'node', 12 | outDir: 'dist', 13 | clean: true, 14 | sourcemap: true, 15 | dts: { 16 | entry: { 17 | mcpli: 'src/mcpli.ts', 18 | 'daemon/wrapper': 'src/daemon/wrapper.ts', 19 | }, 20 | }, 21 | splitting: false, 22 | shims: false, 23 | treeshake: true, 24 | minify: false, 25 | onSuccess: async () => { 26 | console.log('✅ Build complete!'); 27 | 28 | // Set executable permissions for built files 29 | if (existsSync('dist/mcpli.js')) { 30 | chmodSync('dist/mcpli.js', 0o755); 31 | } 32 | if (existsSync('dist/daemon/wrapper.js')) { 33 | chmodSync('dist/daemon/wrapper.js', 0o755); 34 | } 35 | 36 | // Copy daemon files that aren't bundled - wrapper.ts will be compiled to wrapper.js 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.3.0] 6 | 7 | ### Improvements 8 | - Adds SIGINT handling, by way of passing notification/cancellation to MCP server. 9 | 10 | ## [0.2.0] 11 | 12 | ### Improvements 13 | - Fixes issue where tool call timeouts were too aggressive 14 | - Fixes bug where IPC timeout could be lower than tool timeout 15 | - Default tool call timeout is now 10 minutes 16 | - Pass `--tool-timeout=` to extend 17 | - Various fixes 18 | 19 | ## [0.1.3] 20 | 21 | ### Added 22 | - Initial release of MCPLI 23 | - Transform any stdio-based MCP server into a first-class CLI tool 24 | - Persistent daemon architecture for stateful operations 25 | - Natural CLI syntax with auto-generated help 26 | - Standard shell composition with pipes and output redirection 27 | - Flexible parameter syntax supporting all MCP data types 28 | - Multiple daemon support with hash-based identity 29 | - Configurable timeouts with environment variable support 30 | 31 | ## [1.0.0] - TBD 32 | 33 | ### Added 34 | - Initial public release 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Cameron Cooke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/unit/timeout-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('timeout utils', () => { 4 | it('parsePositiveIntMs parses valid positive integers', async () => { 5 | const { __testOnly } = await import('../../src/daemon/mcp-client-utils.ts'); 6 | const { parsePositiveIntMs } = __testOnly as unknown as { 7 | parsePositiveIntMs: (v: unknown) => number | undefined; 8 | }; 9 | 10 | expect(parsePositiveIntMs(1234)).toBe(1234); 11 | expect(parsePositiveIntMs('5000')).toBe(5000); 12 | expect(parsePositiveIntMs(' 42 ')).toBe(42); 13 | }); 14 | 15 | it('parsePositiveIntMs returns undefined for invalid/zero/negative', async () => { 16 | const { __testOnly } = await import('../../src/daemon/mcp-client-utils.ts'); 17 | const { parsePositiveIntMs } = __testOnly as unknown as { 18 | parsePositiveIntMs: (v: unknown) => number | undefined; 19 | }; 20 | 21 | expect(parsePositiveIntMs(undefined)).toBeUndefined(); 22 | expect(parsePositiveIntMs(null)).toBeUndefined(); 23 | expect(parsePositiveIntMs('')).toBeUndefined(); 24 | expect(parsePositiveIntMs('abc')).toBeUndefined(); 25 | expect(parsePositiveIntMs(0)).toBeUndefined(); 26 | expect(parsePositiveIntMs(-5)).toBeUndefined(); 27 | }); 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for MCPLI 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for suggesting a new feature for MCPLI! 10 | 11 | - type: textarea 12 | id: feature-description 13 | attributes: 14 | label: Feature Description 15 | description: Describe the new capability you’d like to add to MCPLI 16 | placeholder: I would like MCPLI to be able to... 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: use-cases 22 | attributes: 23 | label: Use Cases 24 | description: Scenarios where this feature would be useful 25 | placeholder: | 26 | - Run tools in parallel with isolated daemons 27 | - Improve IPC diagnostics for CI 28 | - Add built-in helpers for common MCP servers 29 | validations: 30 | required: false 31 | 32 | - type: textarea 33 | id: example-interactions 34 | attributes: 35 | label: Example Commands 36 | description: Provide examples of how you envision using this feature 37 | placeholder: | 38 | $ mcpli --option value -- 39 | validations: 40 | required: false 41 | 42 | -------------------------------------------------------------------------------- /tests/integration/test-helper.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { createTestEnvironment, TestContext } from '../test-helper.ts'; 3 | 4 | const isDarwin = process.platform === 'darwin'; 5 | 6 | describe.skipIf(!isDarwin)('Test Helper Functionality (macOS only)', () => { 7 | let testCtx: TestContext; 8 | 9 | beforeEach(async () => { 10 | testCtx = await createTestEnvironment(); 11 | }); 12 | 13 | afterEach(async () => { 14 | await testCtx.cleanup(); 15 | }); 16 | 17 | it('creates isolated environment and executes CLI', async () => { 18 | // Test basic CLI execution in isolated environment 19 | const result = await testCtx.cli('--help'); 20 | expect(result.exitCode).toBe(0); // Global help returns 0 21 | expect(result.stdout).toMatch(/Usage:/); 22 | }); 23 | 24 | it('can start and check daemon status', async () => { 25 | const command = 'node'; 26 | const args = ['test-server.js']; 27 | 28 | // Start daemon 29 | const startResult = await testCtx.cli('daemon', 'start', '--', command, ...args); 30 | expect(startResult.exitCode).toBe(0); 31 | 32 | // Check status 33 | const statusResult = await testCtx.cli('daemon', 'status'); 34 | expect(statusResult.exitCode).toBe(0); 35 | expect(statusResult.stdout).toMatch(/PID: \d+/); 36 | }); 37 | }); -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | id-token: write 6 | 7 | on: 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | jobs: 14 | build-and-test: 15 | permissions: 16 | contents: read 17 | id-token: write 18 | runs-on: macos-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [22.x, 24.x] 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'npm' 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Build 37 | run: npm run build 38 | 39 | - name: Lint 40 | run: npm run lint 41 | 42 | - name: Format check 43 | run: npm run format:check 44 | 45 | - name: Type check 46 | run: npm run typecheck 47 | 48 | - name: Run tests (macOS launchd + unit/integration/e2e) 49 | run: npm test 50 | 51 | - name: Coverage report 52 | if: matrix.node-version == '22.x' 53 | run: npm run coverage 54 | 55 | - name: Upload coverage to Codecov 56 | if: matrix.node-version == '22.x' 57 | uses: codecov/codecov-action@v5 58 | with: 59 | files: coverage/lcov.info 60 | use_oidc: true 61 | fail_ci_if_error: false 62 | verbose: true 63 | 64 | - name: Publish PR package (pkg-pr-new) 65 | if: github.event_name == 'pull_request' && matrix.node-version == '22.x' 66 | run: npx pkg-pr-new publish 67 | -------------------------------------------------------------------------------- /src/daemon/mcp-client-utils.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { getConfig } from '../config.ts'; 3 | 4 | /** 5 | * Compute the default timeout (ms) for MCP tool calls. 6 | * Uses environment-driven config with a sane fallback (10 minutes). 7 | */ 8 | export function getDefaultToolTimeoutMs(): number { 9 | const cfg = getConfig(); 10 | const n = Math.trunc(cfg.defaultToolTimeoutMs); 11 | return Number.isFinite(n) && n > 0 ? Math.max(1000, n) : 600_000; 12 | } 13 | 14 | /** 15 | * Parse a value into a positive integer millisecond value. 16 | * Returns undefined when invalid or <= 0. 17 | */ 18 | export function parsePositiveIntMs(v: unknown): number | undefined { 19 | const n = Math.trunc(Number(v)); 20 | return Number.isFinite(n) && n > 0 ? n : undefined; 21 | } 22 | 23 | /** 24 | * Enforce a default timeout on MCP client tool calls unless explicitly overridden. 25 | * Callers can pass a per-call timeout via `options.timeout` to override the default. 26 | */ 27 | export async function callToolWithDefaultTimeout( 28 | client: Client, 29 | params: Parameters[0], 30 | resultSchema?: Parameters[1], 31 | options?: Parameters[2], 32 | ): ReturnType { 33 | const mergedOptions: Parameters[2] = { 34 | timeout: getDefaultToolTimeoutMs(), 35 | ...(options ?? {}), 36 | }; 37 | return client.callTool( 38 | params, 39 | resultSchema as Parameters[1], 40 | mergedOptions, 41 | ) as ReturnType; 42 | } 43 | 44 | // Test-only accessors 45 | export const __testOnly = { 46 | getDefaultToolTimeoutMs, 47 | parsePositiveIntMs, 48 | }; 49 | -------------------------------------------------------------------------------- /tests/integration/ipc-communication.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 2 | import { createTestEnvironment, TestContext } from '../test-helper'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | const PROJECT_ROOT = path.resolve(__dirname, '../..'); 9 | 10 | const isDarwin = process.platform === 'darwin'; 11 | 12 | describe.skipIf(!isDarwin)('IPC communication via launchd socket (macOS only)', () => { 13 | let env: TestContext; 14 | 15 | beforeAll(async () => { 16 | env = await createTestEnvironment(); 17 | await env.cli('daemon', 'clean'); 18 | }); 19 | 20 | afterAll(async () => { 21 | await env.cleanup(); 22 | }); 23 | 24 | it('communicates successfully through launchd-managed daemon', async () => { 25 | const command = 'node'; 26 | const args = [path.join(PROJECT_ROOT, 'test-server.js')]; // Use absolute path from project root 27 | 28 | // Ensure launchd job and sockets exist 29 | const startResult = await env.cli('daemon', 'start', '--', command, ...args); 30 | expect(startResult.exitCode).toBe(0); 31 | 32 | // Extract actual socket path and poll for readiness 33 | const socketMatch = startResult.stdout.match(/Socket: (.+)/); 34 | const socketPath = socketMatch?.[1] ?? env.getSocketPath(env.computeId(command, args)); 35 | await env.pollForSocketPath(socketPath); 36 | 37 | // Test IPC functionality (not start timing) 38 | const echoResult = await env.cli('echo', '--message', 'ipc-test', '--', command, ...args); 39 | expect(echoResult.exitCode).toBe(0); 40 | expect(echoResult.stdout.trim()).toBe('ipc-test'); 41 | }); 42 | }); -------------------------------------------------------------------------------- /tests/e2e/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 2 | import path from 'path'; 3 | import { execa } from 'execa'; 4 | 5 | const isDarwin = process.platform === 'darwin'; 6 | 7 | describe.skipIf(!isDarwin)('mcpli CLI e2e (macOS only)', () => { 8 | const distCli = path.resolve('dist/mcpli.js'); 9 | const server = path.resolve('test-server.js'); 10 | 11 | beforeAll(async () => { 12 | await execa('node', [distCli, 'daemon', 'clean'], { reject: false }); 13 | }); 14 | 15 | afterAll(async () => { 16 | await execa('node', [distCli, 'daemon', 'clean'], { reject: false }); 17 | }); 18 | 19 | it('shows help and lists tools for a given server', async () => { 20 | const helpResult = await execa('node', [distCli, '--help', '--', 'node', server], { 21 | reject: false, 22 | timeout: 15000 23 | }); 24 | expect(helpResult.exitCode).toBe(0); 25 | expect(helpResult.stdout).toMatch(/Usage:/); 26 | expect(helpResult.stdout).toMatch(/Available Tools:/); 27 | }); 28 | 29 | it('runs a tool successfully via launchd-managed daemon', async () => { 30 | const echoResult = await execa('node', [distCli, 'echo', '--message', 'hello', '--', 'node', server], { 31 | reject: false, 32 | timeout: 15000 33 | }); 34 | expect(echoResult.exitCode).toBe(0); 35 | expect(echoResult.stdout.trim()).toBe('hello'); 36 | }); 37 | 38 | it('handles unknown tool with a clear error', async () => { 39 | const errorResult = await execa('node', [distCli, 'not_a_tool', '--', 'node', server], { 40 | reject: false, 41 | timeout: 15000 42 | }); 43 | expect(errorResult.exitCode).toBe(1); 44 | expect(errorResult.stderr).toMatch(/No tool specified or tool not found/i); 45 | }); 46 | }); -------------------------------------------------------------------------------- /tests/unit/daemon-client-aborted-signal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | 3 | const envSnapshot = { ...process.env }; 4 | 5 | vi.mock('../../src/daemon/runtime.ts', () => { 6 | const orchestrator = { 7 | type: 'launchd', 8 | computeId: () => 'test-id', 9 | async ensure(_cmd: string, _args: string[], _opts: unknown) { 10 | return { id: 'test-id', socketPath: '/tmp/fake.sock', updateAction: 'unchanged' }; 11 | }, 12 | async stop() {}, 13 | async status() { 14 | return []; 15 | }, 16 | async clean() {}, 17 | }; 18 | return { 19 | resolveOrchestrator: async () => orchestrator, 20 | computeDaemonId: () => 'test-id', 21 | deriveIdentityEnv: (e: Record) => e, 22 | }; 23 | }); 24 | 25 | const sendIPCRequestMock = vi.fn().mockResolvedValue({ ok: true }); 26 | 27 | vi.mock('../../src/daemon/ipc.ts', () => ({ 28 | generateRequestId: () => 'req-1', 29 | sendIPCRequest: sendIPCRequestMock, 30 | })); 31 | 32 | describe('DaemonClient already-aborted signal', () => { 33 | beforeEach(() => { 34 | // Ensure a clean module registry so env and mocks apply fresh per test 35 | vi.resetModules(); 36 | process.env = { ...envSnapshot }; 37 | sendIPCRequestMock.mockClear(); 38 | }); 39 | 40 | afterEach(() => { 41 | process.env = { ...envSnapshot }; 42 | }); 43 | 44 | it('short-circuits callTool when signal is already aborted', async () => { 45 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 46 | const client = new DaemonClient('fake-server', [], {}); 47 | const ac = new AbortController(); 48 | ac.abort(); 49 | 50 | await expect(client.callTool({ name: 'x' } as any, { signal: ac.signal })).rejects.toThrow( 51 | 'Operation aborted', 52 | ); 53 | expect(sendIPCRequestMock).not.toHaveBeenCalled(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Thank you for helping keep MCPLI and its users safe. We value responsible disclosure and ask that all security reports follow this policy. 4 | 5 | ## Supported Versions 6 | 7 | We generally address security issues on the latest released version. Critical fixes may be backported at the maintainers’ discretion. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report vulnerabilities privately via GitHub Security Advisories: 12 | 13 | 1. Go to the repository’s “Security” tab. 14 | 2. Open “Advisories” and click “Report a vulnerability”. 15 | 3. Provide a clear description, minimal reproduction steps, affected versions (if known), and potential impact. Avoid including sensitive data or exploitation details beyond what’s necessary to reproduce. 16 | 17 | Do not open public issues or pull requests for security reports. 18 | 19 | ## What to Expect 20 | 21 | - We will review and acknowledge your report as promptly as we can. 22 | - We may request additional information to reproduce and assess impact. 23 | - Once a fix is ready, we will coordinate disclosure timing and release notes as appropriate. 24 | 25 | ## Scope 26 | 27 | - In scope: This repository’s source code and distributed packages labeled as MCPLI. 28 | - Out of scope: Third‑party dependencies and components not maintained in this repository. Please report issues to the respective upstreams. 29 | 30 | ## Safe Harbor 31 | 32 | We support good‑faith security research. While testing, please: 33 | 34 | - Do not exploit, disrupt, or degrade service for other users. 35 | - Do not access, modify, or exfiltrate data you do not own. 36 | - Comply with applicable laws and only test on systems you control or have permission to test. 37 | 38 | ## Additional Guidance 39 | 40 | For an end‑user‑oriented overview of MCPLI’s security characteristics (non‑sensitive), see the project’s Security Overview document. 41 | 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcpli", 3 | "version": "0.3.0", 4 | "type": "module", 5 | "description": "Transform stdio-based MCP servers into a first‑class CLI tool", 6 | "keywords": [ 7 | "mcp", 8 | "modelcontextprotocol", 9 | "cli", 10 | "tools", 11 | "agent", 12 | "command-line" 13 | ], 14 | "author": "Cameron Cooke", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/cameroncooke/mcpli.git" 19 | }, 20 | "homepage": "https://github.com/cameroncooke/mcpli", 21 | "bugs": { 22 | "url": "https://github.com/cameroncooke/mcpli/issues" 23 | }, 24 | "bin": { 25 | "mcpli": "dist/mcpli.js" 26 | }, 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "build": "npx tsup", 32 | "dev": "npm run build", 33 | "lint": "eslint 'src/**/*.{js,ts}'", 34 | "lint:fix": "eslint 'src/**/*.{js,ts}' --fix", 35 | "format": "prettier --write 'src/**/*.{js,ts}'", 36 | "format:check": "prettier --check 'src/**/*.{js,ts}'", 37 | "typecheck": "tsc --noEmit", 38 | "test": "vitest --run", 39 | "test:unit": "vitest --run tests/unit", 40 | "test:integration": "vitest --run tests/integration", 41 | "test:e2e": "vitest --run tests/e2e", 42 | "coverage": "vitest run --coverage" 43 | }, 44 | "dependencies": { 45 | "@modelcontextprotocol/sdk": "^1.12.3", 46 | "socket-activation": "^3.2.0", 47 | "zod": "^3.23.8" 48 | }, 49 | "devDependencies": { 50 | "@eslint/js": "^9.23.0", 51 | "@types/node": "^22.10.2", 52 | "@typescript-eslint/eslint-plugin": "^8.28.0", 53 | "@typescript-eslint/parser": "^8.28.0", 54 | "@vitest/coverage-v8": "^3.2.4", 55 | "eslint": "^9.23.0", 56 | "eslint-config-prettier": "^10.1.1", 57 | "eslint-plugin-prettier": "^5.2.5", 58 | "execa": "^9.4.0", 59 | "prettier": "^3.5.3", 60 | "tsup": "^8.5.0", 61 | "typescript": "^5.7.2", 62 | "typescript-eslint": "^8.28.0", 63 | "vitest": "^3.2.4" 64 | }, 65 | "engines": { 66 | "node": ">=22.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/unit/runtime.identity.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import path from 'path'; 3 | import { 4 | computeDaemonId, 5 | normalizeEnv, 6 | normalizeCommand, 7 | deriveIdentityEnv 8 | } from '../../src/daemon/runtime.ts'; 9 | 10 | describe('runtime identity', () => { 11 | it('normalizeEnv sorts keys and coerces values', () => { 12 | const env = normalizeEnv({ B: '2', A: 1 as unknown as string }); 13 | const keys = Object.keys(env); 14 | expect(keys).toEqual(['A', 'B']); 15 | expect(env.A).toBe('1'); 16 | expect(env.B).toBe('2'); 17 | }); 18 | 19 | it('deriveIdentityEnv ignores ambient process.env and uses only explicit env', () => { 20 | const explicit = { FOO: 'x', BAR: 'y' }; 21 | const derived = deriveIdentityEnv(explicit); 22 | expect(derived).toEqual(normalizeEnv(explicit)); 23 | // Ensure no accidental merge from process.env 24 | for (const k of Object.keys(process.env)) { 25 | expect(Object.prototype.hasOwnProperty.call(derived, k)).toBe(false); 26 | } 27 | }); 28 | 29 | it('computeDaemonId is stable regardless of env key ordering', () => { 30 | const cmd = '/usr/bin/node'; 31 | const args = ['/path/to/server.js']; 32 | const env1 = { A: '1', B: '2' }; 33 | const env2 = { B: '2', A: '1' }; 34 | const id1 = computeDaemonId(cmd, args, env1); 35 | const id2 = computeDaemonId(cmd, args, env2); 36 | expect(id1).toBe(id2); 37 | }); 38 | 39 | it('normalizeCommand keeps bare executables unchanged and absolutizes path-like inputs', () => { 40 | // Bare executable should remain as-is 41 | const bare = 'node'; 42 | const outBare = normalizeCommand(bare, ['server.js']); 43 | expect(outBare.command).toBe('node'); 44 | expect(outBare.args).toEqual(['server.js']); 45 | 46 | // Path-like command should be resolved to an absolute path 47 | const pathLike = './server.js'; 48 | const outPath = normalizeCommand(pathLike, []); 49 | expect(path.isAbsolute(outPath.command)).toBe(true); 50 | expect(outPath.command.endsWith('/server.js')).toBe(true); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | prompt: | 40 | Please review this pull request and provide feedback on: 41 | - Code quality and best practices 42 | - Potential bugs or issues 43 | - Performance considerations 44 | - Security concerns 45 | - Test coverage 46 | 47 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 48 | 49 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 50 | 51 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 52 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 53 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 44 | # prompt: 'Update the pull request description to include a summary of changes.' 45 | 46 | # Optional: Add claude_args to customize behavior and configuration 47 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 48 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 49 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 50 | 51 | -------------------------------------------------------------------------------- /tests/unit/daemon-client-midflight-cancel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | 3 | const envSnapshot = { ...process.env }; 4 | 5 | vi.mock('../../src/daemon/runtime.ts', () => { 6 | const orchestrator = { 7 | type: 'launchd', 8 | computeId: () => 'test-id', 9 | async ensure(_cmd: string, _args: string[], _opts: unknown) { 10 | return { id: 'test-id', socketPath: '/tmp/fake.sock', updateAction: 'unchanged' }; 11 | }, 12 | async stop() {}, 13 | async status() { 14 | return []; 15 | }, 16 | async clean() {}, 17 | }; 18 | return { 19 | resolveOrchestrator: async () => orchestrator, 20 | computeDaemonId: () => 'test-id', 21 | deriveIdentityEnv: (e: Record) => e, 22 | }; 23 | }); 24 | 25 | let startResolve: () => void; 26 | let sendIPCRequestStarted: Promise; 27 | 28 | const sendIPCRequestMock = vi.fn( 29 | ( 30 | _socketPath: string, 31 | _request: unknown, 32 | _timeoutMs: number, 33 | _connectRetryBudgetMs?: number, 34 | signal?: AbortSignal, 35 | ) => { 36 | startResolve(); 37 | return new Promise((_, reject) => { 38 | signal?.addEventListener('abort', () => reject(new Error('ipc aborted')), { once: true }); 39 | }); 40 | }, 41 | ); 42 | 43 | vi.mock('../../src/daemon/ipc.ts', () => ({ 44 | generateRequestId: () => 'req-1', 45 | sendIPCRequest: sendIPCRequestMock, 46 | })); 47 | 48 | describe('DaemonClient mid-flight cancellation', () => { 49 | beforeEach(() => { 50 | process.env = { ...envSnapshot }; 51 | sendIPCRequestMock.mockClear(); 52 | sendIPCRequestStarted = new Promise((resolve) => { 53 | startResolve = resolve; 54 | }); 55 | }); 56 | 57 | afterEach(() => { 58 | process.env = { ...envSnapshot }; 59 | }); 60 | 61 | it('rejects callTool promptly when signal aborts during request', async () => { 62 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 63 | const client = new DaemonClient('fake-server', [], {}); 64 | const ac = new AbortController(); 65 | const callPromise = client.callTool({ name: 'x' } as any, { signal: ac.signal }); 66 | await sendIPCRequestStarted; 67 | ac.abort(); 68 | await expect(callPromise).rejects.toThrow('Operation aborted'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/unit/ipc.limits.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { 3 | __testGetIpcLimits, 4 | __testGetIpcServerTunables 5 | } from '../../src/daemon/ipc.ts'; 6 | 7 | const envSnapshot = { ...process.env }; 8 | 9 | describe('IPC limits and tunables', () => { 10 | beforeEach(() => { 11 | process.env = { ...envSnapshot }; 12 | delete process.env.MCPLI_IPC_MAX_FRAME_BYTES; 13 | delete process.env.MCPLI_IPC_MAX_CONNECTIONS; 14 | delete process.env.MCPLI_IPC_CONNECTION_IDLE_TIMEOUT_MS; 15 | delete process.env.MCPLI_IPC_LISTEN_BACKLOG; 16 | }); 17 | 18 | afterEach(() => { 19 | process.env = { ...envSnapshot }; 20 | }); 21 | 22 | it('defaults produce sane limits below hard kill threshold', () => { 23 | const { maxFrameBytes, killThresholdBytes } = __testGetIpcLimits(); 24 | expect(maxFrameBytes).toBeGreaterThan(0); 25 | expect(killThresholdBytes).toBeGreaterThan(maxFrameBytes); 26 | }); 27 | 28 | it('clamps soft limit below hard kill threshold', () => { 29 | const { killThresholdBytes } = __testGetIpcLimits(); 30 | // Request a value at or above hard threshold; should clamp 31 | process.env.MCPLI_IPC_MAX_FRAME_BYTES = String(killThresholdBytes); 32 | const { maxFrameBytes } = __testGetIpcLimits(); 33 | expect(maxFrameBytes).toBeLessThan(killThresholdBytes); 34 | }); 35 | 36 | it('server tunables clamp to configured ranges', () => { 37 | // Overly large values should clamp down 38 | process.env.MCPLI_IPC_MAX_CONNECTIONS = '50000'; 39 | process.env.MCPLI_IPC_CONNECTION_IDLE_TIMEOUT_MS = '9999999'; 40 | process.env.MCPLI_IPC_LISTEN_BACKLOG = '999999'; 41 | 42 | const t = __testGetIpcServerTunables(); 43 | expect(t.maxConnections).toBeLessThanOrEqual(1000); 44 | expect(t.connectionIdleTimeoutMs).toBeLessThanOrEqual(600000); 45 | expect(t.listenBacklog).toBeLessThanOrEqual(2048); 46 | }); 47 | 48 | it('server tunables accept valid configured values', () => { 49 | process.env.MCPLI_IPC_MAX_CONNECTIONS = '128'; 50 | process.env.MCPLI_IPC_CONNECTION_IDLE_TIMEOUT_MS = '20000'; 51 | process.env.MCPLI_IPC_LISTEN_BACKLOG = '256'; 52 | 53 | const t = __testGetIpcServerTunables(); 54 | expect(t.maxConnections).toBe(128); 55 | expect(t.connectionIdleTimeoutMs).toBe(20000); 56 | expect(t.listenBacklog).toBe(256); 57 | }); 58 | }); -------------------------------------------------------------------------------- /tests/unit/safety.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { 3 | isUnsafeKey, 4 | safeEmptyRecord, 5 | safeDefine, 6 | deepSanitize 7 | } from '../../src/utils/safety.ts'; 8 | 9 | describe('safety utilities', () => { 10 | it('safeEmptyRecord returns null-prototype object', () => { 11 | const rec = safeEmptyRecord(); 12 | expect(Object.getPrototypeOf(rec)).toBe(null); 13 | }); 14 | 15 | it('isUnsafeKey correctly identifies dangerous keys', () => { 16 | expect(isUnsafeKey('__proto__')).toBe(true); 17 | expect(isUnsafeKey('constructor')).toBe(true); 18 | expect(isUnsafeKey('prototype')).toBe(true); 19 | expect(isUnsafeKey('safe')).toBe(false); 20 | }); 21 | 22 | it('safeDefine defines enumerable data property', () => { 23 | const o = safeEmptyRecord(); 24 | safeDefine(o, 'foo', 42); 25 | expect(o.foo).toBe(42); 26 | expect(Object.getOwnPropertyDescriptor(o, 'foo')?.enumerable).toBe(true); 27 | }); 28 | 29 | it('safeDefine rejects dangerous keys', () => { 30 | const o = safeEmptyRecord(); 31 | expect(() => safeDefine(o, '__proto__', 'x')).toThrow(); 32 | expect(() => safeDefine(o, 'constructor', 'x')).toThrow(); 33 | }); 34 | 35 | it('deepSanitize removes dangerous keys and rehydrates objects safely', () => { 36 | // Create input object with dangerous keys as actual own properties 37 | const input: Record = {}; 38 | Object.defineProperty(input, '__proto__', { value: { polluted: true }, enumerable: true }); 39 | input.safe = 'ok'; 40 | input.nested = { 41 | constructor: 'nope', 42 | good: [ 43 | Object.defineProperty({}, 'prototype', { value: 'bad', enumerable: true }), 44 | { foo: 'bar' } 45 | ] 46 | }; 47 | 48 | const out = deepSanitize(input) as Record; 49 | 50 | // Should not have dangerous keys as own properties 51 | expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(false); 52 | expect(out.safe).toBe('ok'); 53 | expect(Object.prototype.hasOwnProperty.call(out.nested, 'constructor')).toBe(false); 54 | expect(Object.prototype.hasOwnProperty.call(out.nested.good[0], 'prototype')).toBe(false); 55 | expect(out.nested.good[1].foo).toBe('bar'); 56 | // ensure null-prototype objects are used 57 | expect(Object.getPrototypeOf(out)).toBe(null); 58 | expect(Object.getPrototypeOf(out.nested)).toBe(null); 59 | }); 60 | }); -------------------------------------------------------------------------------- /tests/integration/daemon-lifecycle.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeAll, afterAll, expect } from 'vitest'; 2 | import { createTestEnvironment, TestContext } from '../test-helper'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | const PROJECT_ROOT = path.resolve(__dirname, '../..'); 9 | 10 | const isDarwin = process.platform === 'darwin'; 11 | 12 | describe.skipIf(!isDarwin)('Launchd daemon lifecycle (macOS only)', () => { 13 | let env: TestContext; 14 | 15 | beforeAll(async () => { 16 | // Isolated temp directory per test suite and initial clean 17 | env = await createTestEnvironment(); 18 | await env.cli('daemon', 'clean'); 19 | }); 20 | 21 | afterAll(async () => { 22 | // Cleanup temp directory and any daemons 23 | await env.cleanup(); 24 | }); 25 | 26 | it('starts daemon and makes basic tool call', async () => { 27 | const command = 'node'; 28 | const args = [path.join(PROJECT_ROOT, 'test-server.js')]; // Use absolute path from project root 29 | 30 | // Start daemon via CLI (should return immediately after registering launchd job) 31 | const startResult = await env.cli('daemon', 'start', '--', command, ...args); 32 | expect(startResult.exitCode).toBe(0); 33 | 34 | // Extract actual socket path from output (follows industry pattern) 35 | const socketMatch = startResult.stdout.match(/Socket: (.+)/); 36 | let socketPath: string | undefined = socketMatch?.[1]; 37 | if (!socketPath) { 38 | // Fallback to deterministic ID/path 39 | const id = env.computeId(command, args); 40 | socketPath = env.getSocketPath(id); 41 | // If this still fails, include diagnostics 42 | if (!socketPath) { 43 | throw new Error( 44 | `Could not determine socket path.\nstdout:\n${startResult.stdout}\nstderr:\n${startResult.stderr}` 45 | ); 46 | } 47 | } 48 | 49 | // Poll for socket readiness using actual path 50 | await env.pollForSocketPath(socketPath); 51 | 52 | // Validate daemon functionality via IPC (echo) 53 | const echoResult = await env.cli('echo', '--message', 'hello', '--', command, ...args); 54 | expect(echoResult.exitCode).toBe(0); 55 | expect(echoResult.stdout.trim()).toBe('hello'); 56 | }); 57 | 58 | it('verifies daemon status command works', async () => { 59 | const statusResult = await env.cli('daemon', 'status'); 60 | expect(statusResult.exitCode).toBe(0); 61 | expect(statusResult.stdout).toMatch(/No daemons found|PID: \d+|Running: yes|Running: no/); 62 | }); 63 | }); -------------------------------------------------------------------------------- /src/utils/safety.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Safety utilities to prevent prototype pollution for untrusted key/value aggregation. 3 | */ 4 | 5 | export const DANGEROUS_KEYS: ReadonlySet = new Set([ 6 | '__proto__', 7 | 'prototype', 8 | 'constructor', 9 | ]); 10 | 11 | export function isUnsafeKey(key: string): boolean { 12 | return DANGEROUS_KEYS.has(key); 13 | } 14 | 15 | /** 16 | * Create a null-prototype object for safe key/value storage. 17 | */ 18 | export function safeEmptyRecord(): Record { 19 | return Object.create(null) as Record; 20 | } 21 | 22 | /** 23 | * Safely define a property on an object, rejecting dangerous keys 24 | * and ensuring a data property is created (not invoking accessors). 25 | */ 26 | export function safeDefine>( 27 | obj: T, 28 | key: string, 29 | value: unknown, 30 | ): void { 31 | if (isUnsafeKey(key)) { 32 | throw new Error(`Unsafe key "${key}" is not allowed.`); 33 | } 34 | Object.defineProperty(obj, key, { 35 | value, 36 | enumerable: true, 37 | writable: true, 38 | configurable: true, 39 | }); 40 | } 41 | 42 | /** 43 | * Deeply sanitize arrays and objects by removing dangerous keys 44 | * and rehydrating objects as null-prototype records. 45 | */ 46 | export function deepSanitize(value: T): T { 47 | return _deepSanitize(value, new WeakMap()) as T; 48 | } 49 | 50 | function isPlainObject(v: unknown): v is Record { 51 | if (v === null || typeof v !== 'object') return false; 52 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 53 | const proto = Object.getPrototypeOf(v); 54 | return proto === Object.prototype || proto === null; 55 | } 56 | 57 | function _deepSanitize(value: unknown, seen: WeakMap): unknown { 58 | if (Array.isArray(value)) { 59 | const cached = seen.get(value); 60 | if (cached) return cached; 61 | const out = value.map((v) => _deepSanitize(v, seen)); 62 | seen.set(value, out); 63 | return out; 64 | } 65 | if (value !== null && typeof value === 'object') { 66 | const obj = value as object; 67 | const cached = seen.get(obj); 68 | if (cached) return cached; 69 | if (!isPlainObject(obj)) { 70 | // Leave non-plain objects (Date, Map, Buffer, TypedArray, etc.) intact 71 | return value; 72 | } 73 | const src = obj as Record; 74 | const out = safeEmptyRecord(); 75 | seen.set(obj, out); 76 | for (const k of Object.keys(src)) { 77 | if (isUnsafeKey(k)) continue; 78 | safeDefine(out, k, _deepSanitize(src[k], seen)); 79 | } 80 | return out; 81 | } 82 | return value; 83 | } 84 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | - `src/`: TypeScript source. Entry is `src/mcpli.ts`; daemon code in `src/daemon/` (runtime, IPC, wrapper); config in `src/config.ts`; utilities in `src/utils/`. 5 | - `tests/`: Vitest specs split by `unit/`, `integration/`, and `e2e/`. Test helpers in `tests/test-helper.ts`. 6 | - `dist/`: Build output (`mcpli.js`, `daemon/wrapper.js`). Do not edit manually. 7 | - `scripts/`: Maintenance scripts (`release.sh`, `test-regression.sh`). 8 | - Sample MCP servers for local testing: `weather-server.js`, `test-server.js`, `complex-test-server.js`. 9 | 10 | ## Build, Test, and Development Commands 11 | - `npm run build`: Bundle with tsup (targets Node 22). Outputs to `dist/` and sets execute bits. 12 | - `npm run dev`: Convenience rebuild (same as build) for iterative work. 13 | - `npm run typecheck`: TypeScript checks without emit. 14 | - `npm run lint` | `npm run lint:fix`: Lint (ESLint) and optionally fix. 15 | - `npm test`: Run all tests (Vitest). Variants: `test:unit`, `test:integration`, `test:e2e`, `coverage`. 16 | - Local CLI example: `./dist/mcpli.js --help -- node weather-server.js`. 17 | 18 | ## Coding Style & Naming Conventions 19 | - Formatting: Prettier (2‑space indent, single quotes, semicolons, trailing commas, width 100). Check with `npm run format:check`; write with `npm run format`. 20 | - Linting: ESLint + TypeScript ESLint. In `src/`, avoid `any`, prefer explicit return types, and do not import `*.js` from TS (`no-restricted-imports`). 21 | - File names: use `kebab-case` for files, `PascalCase` for types/classes, `camelCase` for variables/functions. 22 | 23 | ## Testing Guidelines 24 | - Framework: Vitest with Node environment; coverage via V8 (`text`, `html`, `lcov`). 25 | - Location/patterns: `tests/**/**/*.test.ts`. Keep fast unit tests under `tests/unit` and heavier flows under `integration`/`e2e`. 26 | - Isolation: use helpers in `tests/test-helper.ts` (temp dirs, daemon polling) for stable daemon tests. 27 | 28 | ## Commit & Pull Request Guidelines 29 | - Use Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`). Example: `feat: add daemon status subcommand`. 30 | - PRs: include a clear description, linked issues (`closes #123`), test instructions, and update `CHANGELOG.md` when user-facing. 31 | - Pre‑merge checklist: `npm run build`, `npm run typecheck`, `npm run lint`, and passing tests. 32 | 33 | ## Security & Configuration Tips 34 | - Runtime: Node ≥ 22 (see `package.json engines`). 35 | - Daemon logs: inspect `.mcpli/daemon.log`; avoid committing `.mcpli/` artifacts. 36 | - When passing env to servers, prefer simple `KEY=value` pairs; unsafe keys (e.g., `__proto__`) are rejected by design. 37 | 38 | -------------------------------------------------------------------------------- /tests/unit/mcp-client-timeout.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | 3 | const envSnapshot = { ...process.env }; 4 | 5 | describe('MCP client default tool timeout', () => { 6 | beforeEach(() => { 7 | process.env = { ...envSnapshot }; 8 | delete process.env.MCPLI_TOOL_TIMEOUT_MS; 9 | }); 10 | 11 | afterEach(() => { 12 | process.env = { ...envSnapshot }; 13 | }); 14 | 15 | it('uses 10 minutes (600000 ms) by default', async () => { 16 | const { getDefaultToolTimeoutMs } = await import('../../src/daemon/mcp-client-utils.ts'); 17 | expect(getDefaultToolTimeoutMs()).toBe(600000); 18 | }); 19 | 20 | it('honors MCPLI_TOOL_TIMEOUT_MS when set to a valid positive integer', async () => { 21 | process.env.MCPLI_TOOL_TIMEOUT_MS = '900000'; 22 | const { getDefaultToolTimeoutMs } = await import('../../src/daemon/mcp-client-utils.ts'); 23 | expect(getDefaultToolTimeoutMs()).toBe(900000); 24 | }); 25 | 26 | it('falls back to default when MCPLI_TOOL_TIMEOUT_MS is invalid', async () => { 27 | process.env.MCPLI_TOOL_TIMEOUT_MS = 'not-a-number'; 28 | const { getDefaultToolTimeoutMs } = await import('../../src/daemon/mcp-client-utils.ts'); 29 | expect(getDefaultToolTimeoutMs()).toBe(600000); 30 | }); 31 | 32 | it('applies default timeout when options are not provided', async () => { 33 | process.env.MCPLI_TOOL_TIMEOUT_MS = '700000'; 34 | const { callToolWithDefaultTimeout } = await import('../../src/daemon/mcp-client-utils.ts'); 35 | 36 | const captured: { options?: unknown } = {}; 37 | const fakeClient = { 38 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 39 | async callTool(_params: unknown, _schema?: unknown, options?: unknown): Promise { 40 | captured.options = options; 41 | return { ok: true }; 42 | }, 43 | } as unknown as import('@modelcontextprotocol/sdk/client/index.js').Client; 44 | 45 | const res = await callToolWithDefaultTimeout(fakeClient, { name: 'x' }); 46 | expect(res).toEqual({ ok: true }); 47 | const opts = captured.options as { timeout?: number } | undefined; 48 | expect(opts?.timeout).toBe(700000); 49 | }); 50 | 51 | it('allows per-call override of timeout via options', async () => { 52 | process.env.MCPLI_TOOL_TIMEOUT_MS = '700000'; 53 | const { callToolWithDefaultTimeout } = await import('../../src/daemon/mcp-client-utils.ts'); 54 | 55 | const captured: { options?: unknown } = {}; 56 | const fakeClient = { 57 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 58 | async callTool(_params: unknown, _schema?: unknown, options?: unknown): Promise { 59 | captured.options = options; 60 | return { ok: true }; 61 | }, 62 | } as unknown as import('@modelcontextprotocol/sdk/client/index.js').Client; 63 | 64 | await callToolWithDefaultTimeout(fakeClient, { name: 'x' }, undefined, { timeout: 12345 }); 65 | const opts = captured.options as { timeout?: number } | undefined; 66 | expect(opts?.timeout).toBe(12345); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Centralized configuration for MCPLI 3 | * 4 | * Priority order: 5 | * 1. CLI arguments (highest priority) 6 | * 2. Environment variables 7 | * 3. Default values (lowest priority) 8 | */ 9 | 10 | function parsePositiveIntEnv(key: string, fallback: number): number { 11 | const raw = process.env[key]; 12 | const n = raw === undefined ? Number.NaN : Number.parseInt(String(raw).trim(), 10); 13 | return Number.isFinite(n) && n > 0 ? Math.trunc(n) : fallback; 14 | } 15 | 16 | export interface MCPLIConfig { 17 | /** Default daemon inactivity timeout in seconds */ 18 | defaultTimeoutSeconds: number; 19 | /** Default CLI operation timeout in seconds */ 20 | defaultCliTimeoutSeconds: number; 21 | /** Default IPC connection timeout in milliseconds */ 22 | defaultIpcTimeoutMs: number; 23 | /** Default MCP client tool timeout in milliseconds */ 24 | defaultToolTimeoutMs: number; 25 | } 26 | 27 | /** 28 | * Environment variable names for configuration 29 | */ 30 | export const ENV_VARS = { 31 | /** Daemon inactivity timeout in seconds */ 32 | MCPLI_DEFAULT_TIMEOUT: 'MCPLI_DEFAULT_TIMEOUT', 33 | /** CLI operation timeout in seconds */ 34 | MCPLI_CLI_TIMEOUT: 'MCPLI_CLI_TIMEOUT', 35 | /** IPC connection timeout in milliseconds */ 36 | MCPLI_IPC_TIMEOUT: 'MCPLI_IPC_TIMEOUT', 37 | /** Preferred user-facing tool timeout in milliseconds */ 38 | MCPLI_TOOL_TIMEOUT_MS: 'MCPLI_TOOL_TIMEOUT_MS', 39 | } as const; 40 | 41 | /** 42 | * Default configuration values 43 | */ 44 | const DEFAULT_CONFIG: MCPLIConfig = { 45 | defaultTimeoutSeconds: 1800, // 30 minutes 46 | defaultCliTimeoutSeconds: 30, // 30 seconds 47 | // IPC timeout must exceed tool timeout; set to tool (10m) + 1m buffer 48 | defaultIpcTimeoutMs: 660000, // 11 minutes 49 | defaultToolTimeoutMs: 600000, // 10 minutes 50 | }; 51 | 52 | /** 53 | * Get the current MCPLI configuration, considering environment variables 54 | */ 55 | export function getConfig(): MCPLIConfig { 56 | return { 57 | defaultTimeoutSeconds: parsePositiveIntEnv( 58 | ENV_VARS.MCPLI_DEFAULT_TIMEOUT, 59 | DEFAULT_CONFIG.defaultTimeoutSeconds, 60 | ), 61 | defaultCliTimeoutSeconds: parsePositiveIntEnv( 62 | ENV_VARS.MCPLI_CLI_TIMEOUT, 63 | DEFAULT_CONFIG.defaultCliTimeoutSeconds, 64 | ), 65 | defaultIpcTimeoutMs: parsePositiveIntEnv( 66 | ENV_VARS.MCPLI_IPC_TIMEOUT, 67 | DEFAULT_CONFIG.defaultIpcTimeoutMs, 68 | ), 69 | // Front-facing tool timeout only 70 | defaultToolTimeoutMs: parsePositiveIntEnv( 71 | ENV_VARS.MCPLI_TOOL_TIMEOUT_MS, 72 | DEFAULT_CONFIG.defaultToolTimeoutMs, 73 | ), 74 | }; 75 | } 76 | 77 | /** 78 | * Resolve the daemon timeout to use, with priority: 79 | * 1. CLI argument (if provided) 80 | * 2. Environment variable 81 | * 3. Default value 82 | */ 83 | export function resolveDaemonTimeout(cliTimeout?: number): number { 84 | if (cliTimeout != null) { 85 | return Math.max(1, Math.trunc(cliTimeout)); 86 | } 87 | 88 | const config = getConfig(); 89 | return config.defaultTimeoutSeconds; 90 | } 91 | 92 | /** 93 | * Get daemon timeout in milliseconds (for internal use) 94 | */ 95 | export function getDaemonTimeoutMs(cliTimeout?: number): number { 96 | return resolveDaemonTimeout(cliTimeout) * 1000; 97 | } 98 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or issue with MCPLI 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to report an issue with MCPLI! 10 | 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Bug Description 15 | description: A concise description of the issue you’re experiencing. 16 | placeholder: When trying to run a tool via MCPLI... 17 | validations: 18 | required: true 19 | 20 | - type: input 21 | id: mcpli-version 22 | attributes: 23 | label: MCPLI Version 24 | description: Output of `mcpli --version` or the commit SHA you’re using 25 | placeholder: 1.0.0 or abcdef12 26 | validations: 27 | required: true 28 | 29 | - type: input 30 | id: node-version 31 | attributes: 32 | label: Node.js Version 33 | placeholder: v22.6.0 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: macos-version 39 | attributes: 40 | label: macOS Version 41 | placeholder: 15.5 (arm64) 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: commands 47 | attributes: 48 | label: Commands Run 49 | description: Paste the exact MCPLI command(s) you ran, including the MCP server command after `--`. 50 | placeholder: | 51 | $ mcpli get-weather --location "London" -- node weather-server.js 52 | $ mcpli --debug get-weather --location "London" -- node weather-server.js 53 | render: shell 54 | validations: 55 | required: true 56 | 57 | - type: textarea 58 | id: steps 59 | attributes: 60 | label: Steps to Reproduce 61 | description: Steps to reproduce the behavior 62 | placeholder: | 63 | 1. Exact command you ran 64 | 2. What you expected to happen 65 | 3. What actually happened 66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | id: expected 71 | attributes: 72 | label: Expected Behavior 73 | validations: 74 | required: true 75 | 76 | - type: textarea 77 | id: actual 78 | attributes: 79 | label: Actual Behavior 80 | validations: 81 | required: true 82 | 83 | - type: textarea 84 | id: env 85 | attributes: 86 | label: Environment Variables (if any) 87 | description: List any env vars you set before running MCPLI 88 | placeholder: | 89 | MCPLI_DEFAULT_TIMEOUT=600 90 | API_KEY=... 91 | render: shell 92 | validations: 93 | required: false 94 | 95 | - type: textarea 96 | id: daemon-status 97 | attributes: 98 | label: Daemon Status (optional) 99 | description: Output of `mcpli daemon status` 100 | render: shell 101 | validations: 102 | required: false 103 | 104 | - type: textarea 105 | id: debug-logs 106 | attributes: 107 | label: Debug Logs (optional) 108 | description: Run the failing command with `--debug` and paste relevant excerpt 109 | render: shell 110 | validations: 111 | required: false 112 | -------------------------------------------------------------------------------- /tests/unit/daemon-client-connect-retry.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | 3 | const envSnapshot = { ...process.env }; 4 | let __capturedBudget: number | undefined; 5 | 6 | // Mock orchestrator with mutable behavior flags 7 | let __mockEnsureUpdateAction: 'loaded' | 'reloaded' | 'unchanged' = 'unchanged'; 8 | let __mockEnsureStarted = false; 9 | 10 | vi.mock('../../src/daemon/runtime.ts', () => { 11 | const orchestrator = { 12 | type: 'launchd', 13 | computeId: () => 'test-id', 14 | async ensure(_cmd: string, _args: string[], _opts: unknown) { 15 | return { 16 | id: 'test-id', 17 | socketPath: '/tmp/fake.sock', 18 | updateAction: __mockEnsureUpdateAction, 19 | started: __mockEnsureStarted, 20 | }; 21 | }, 22 | async stop() {}, 23 | async status() { return []; }, 24 | async clean() {}, 25 | }; 26 | return { 27 | resolveOrchestrator: async () => orchestrator, 28 | computeDaemonId: () => 'test-id', 29 | deriveIdentityEnv: (e: Record) => e, 30 | }; 31 | }); 32 | 33 | describe('DaemonClient adaptive connect retry budget', () => { 34 | beforeEach(() => { 35 | process.env = { ...envSnapshot }; 36 | vi.resetModules(); 37 | }); 38 | 39 | afterEach(() => { 40 | process.env = { ...envSnapshot }; 41 | vi.clearAllMocks(); 42 | }); 43 | 44 | it('passes extended connect retry budget (8s) when job was loaded/reloaded/started', async () => { 45 | __mockEnsureUpdateAction = 'reloaded'; 46 | __mockEnsureStarted = true; 47 | 48 | let capturedBudget: number | undefined; 49 | __capturedBudget = undefined; 50 | vi.mock('../../src/daemon/ipc.ts', () => ({ 51 | generateRequestId: () => 'req-1', 52 | // Capture the 4th argument (connectRetryBudgetMs) 53 | async sendIPCRequest( 54 | _socketPath: string, 55 | _request: unknown, 56 | _timeoutMs: number, 57 | connectRetryBudgetMs?: number, 58 | _signal?: AbortSignal, 59 | ) { 60 | __capturedBudget = connectRetryBudgetMs; 61 | return { ok: true }; 62 | }, 63 | })); 64 | 65 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 66 | const client = new DaemonClient('fake-server', [], {}); 67 | await client.callTool({ name: 'x' } as any); 68 | 69 | expect(__capturedBudget).toBe(8000); 70 | }); 71 | 72 | it('uses default connect retry budget when updateAction is unchanged and not started', async () => { 73 | __mockEnsureUpdateAction = 'unchanged'; 74 | __mockEnsureStarted = false; 75 | 76 | let capturedBudget: number | undefined; 77 | __capturedBudget = undefined; 78 | vi.mock('../../src/daemon/ipc.ts', () => ({ 79 | generateRequestId: () => 'req-1', 80 | async sendIPCRequest( 81 | _socketPath: string, 82 | _request: unknown, 83 | _timeoutMs: number, 84 | connectRetryBudgetMs?: number, 85 | _signal?: AbortSignal, 86 | ) { 87 | __capturedBudget = connectRetryBudgetMs; 88 | return { ok: true }; 89 | }, 90 | })); 91 | 92 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 93 | const client = new DaemonClient('fake-server', [], {}); 94 | await client.listTools(); 95 | 96 | expect(__capturedBudget).toBeUndefined(); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /timeout-test-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | 10 | const server = new Server( 11 | { 12 | name: 'timeout-test-server', 13 | version: '1.0.0', 14 | }, 15 | { 16 | capabilities: { 17 | tools: {}, 18 | }, 19 | } 20 | ); 21 | 22 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 23 | tools: [ 24 | { 25 | name: 'delay', 26 | description: 'Delays for the specified number of seconds', 27 | inputSchema: { 28 | type: 'object', 29 | properties: { 30 | seconds: { 31 | type: 'number', 32 | description: 'Number of seconds to delay', 33 | }, 34 | }, 35 | required: ['seconds'], 36 | }, 37 | }, 38 | { 39 | name: 'quick', 40 | description: 'Returns immediately', 41 | inputSchema: { 42 | type: 'object', 43 | properties: {}, 44 | }, 45 | }, 46 | ], 47 | })); 48 | 49 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 50 | const startTime = Date.now(); 51 | console.error(`[${new Date().toISOString()}] Tool call started: ${request.params.name}`); 52 | 53 | try { 54 | switch (request.params.name) { 55 | case 'delay': { 56 | const seconds = request.params.arguments?.seconds || 30; 57 | console.error(`[${new Date().toISOString()}] Starting delay for ${seconds} seconds`); 58 | 59 | // Log progress every 10 seconds 60 | let elapsed = 0; 61 | const interval = setInterval(() => { 62 | elapsed += 10; 63 | if (elapsed < seconds) { 64 | console.error(`[${new Date().toISOString()}] Still delaying... ${elapsed}/${seconds} seconds elapsed`); 65 | } 66 | }, 10000); 67 | 68 | await new Promise(resolve => setTimeout(resolve, seconds * 1000)); 69 | clearInterval(interval); 70 | 71 | const totalTime = (Date.now() - startTime) / 1000; 72 | console.error(`[${new Date().toISOString()}] Delay completed after ${totalTime.toFixed(1)} seconds`); 73 | 74 | return { 75 | content: [ 76 | { 77 | type: 'text', 78 | text: `Successfully delayed for ${seconds} seconds (actual: ${totalTime.toFixed(1)}s)`, 79 | }, 80 | ], 81 | }; 82 | } 83 | 84 | case 'quick': { 85 | console.error(`[${new Date().toISOString()}] Quick tool returning immediately`); 86 | return { 87 | content: [ 88 | { 89 | type: 'text', 90 | text: 'Quick response!', 91 | }, 92 | ], 93 | }; 94 | } 95 | 96 | default: 97 | throw new Error(`Unknown tool: ${request.params.name}`); 98 | } 99 | } catch (error) { 100 | console.error(`[${new Date().toISOString()}] Tool error:`, error); 101 | throw error; 102 | } 103 | }); 104 | 105 | async function main() { 106 | console.error(`[${new Date().toISOString()}] Starting timeout-test-server...`); 107 | const transport = new StdioServerTransport(); 108 | await server.connect(transport); 109 | console.error(`[${new Date().toISOString()}] timeout-test-server running on stdio`); 110 | } 111 | 112 | main().catch((error) => { 113 | console.error('Server error:', error); 114 | process.exit(1); 115 | }); -------------------------------------------------------------------------------- /security.md: -------------------------------------------------------------------------------- 1 | # Security Overview 2 | 3 | This document provides a high‑level overview of the security characteristics of MCPLI for end users. It is intentionally non‑exhaustive and avoids operational details that could aid misuse. MCPLI prioritizes secure defaults, defense‑in‑depth, and privacy‑preserving behavior while remaining practical for local development and automation. 4 | 5 | ## Goals and Scope 6 | 7 | - Provide confidence that MCPLI’s default operation is safe and robust for local use. 8 | - Describe protections without revealing sensitive implementation specifics. 9 | - Clarify responsibilities that remain with users and MCP server authors. 10 | 11 | MCPLI is not a sandbox. It orchestrates and talks to MCP servers you choose to run. Trust and secure those servers as you would other local tooling. 12 | 13 | ## Core Protections 14 | 15 | - Process Isolation and Lifecycle 16 | - Each unique MCP server configuration runs in its own long‑lived daemon, isolated per project directory. 17 | - Daemons auto‑shut down after periods of inactivity to free resources. 18 | - The daemon verifies its computed identity before starting, preventing accidental or forged mismatches. 19 | 20 | - Local IPC Security 21 | - Communication uses private, local IPC channels with restrictive permissions. 22 | - Connection‑level controls enforce short handshake/idleness timeouts and cap concurrent clients. 23 | - Message‑size safety limits prevent runaway memory use; oversize requests are rejected. 24 | 25 | - Input and Data Handling 26 | - CLI environment variables after `--` are parsed with strict rules and validated names. 27 | - Tool parameters are parsed according to schema where available; JSON inputs are sanitized to remove dangerous prototype keys. 28 | - Only the explicitly provided server environment (after `--`) influences daemon identity, avoiding accidental coupling to ambient shell state. 29 | 30 | - Filesystem and Permissions 31 | - Project and runtime artifacts are written under private, per‑project locations using restrictive permissions. 32 | - Temporary and metadata files are created atomically and cleaned up safely. 33 | - Socket cleanup only removes known socket/symlink paths to avoid accidental deletion of regular files. 34 | 35 | - Subprocess and Environment Safety 36 | - Subprocesses are launched without invoking a shell, reducing command‑injection risk. 37 | - Control/diagnostic variables are kept internal and are not propagated to your MCP server’s environment. 38 | 39 | - Logging and Privacy 40 | - Diagnostic logging avoids exposing secrets and is designed for local development observability. 41 | - No network logging or telemetry is performed by MCPLI. 42 | 43 | - Secure Defaults and Quality Gates 44 | - Modern runtime requirements and strict lint/type rules reduce unsafe coding patterns. 45 | - Automated tests exercise key safety behavior (e.g., input sanitation and IPC limits). 46 | 47 | ## User Responsibilities 48 | 49 | - Run trusted MCP servers and review their behavior; MCPLI does not sandbox server code. 50 | - Avoid passing secrets via command line where they could be exposed by your shell history. 51 | - Keep MCPLI, Node.js, and your OS up to date. 52 | - Do not run MCPLI or servers with elevated privileges unless strictly necessary. 53 | 54 | ## Reporting Vulnerabilities 55 | 56 | Please use GitHub’s private Security Advisories to report potential vulnerabilities. Avoid filing public issues for security‑sensitive reports. We appreciate responsible disclosure and will work with you to triage and remediate. 57 | 58 | ## Changes and Versioning 59 | 60 | This overview focuses on principles and user‑facing guarantees rather than exact implementation details. Internals may evolve to strengthen defenses without changing the guarantees stated here. 61 | 62 | -------------------------------------------------------------------------------- /complex-test-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | ListToolsRequestSchema, 7 | CallToolRequestSchema 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | 10 | const server = new Server( 11 | { 12 | name: 'complex-test-server', 13 | version: '1.0.0', 14 | }, 15 | { 16 | capabilities: { 17 | tools: {}, 18 | }, 19 | } 20 | ); 21 | 22 | // Test tool with comprehensive JSON Schema data types 23 | server.setRequestHandler(ListToolsRequestSchema, async () => { 24 | return { 25 | tools: [ 26 | { 27 | name: 'test_all_types', 28 | description: 'Test tool with all JSON Schema data types', 29 | inputSchema: { 30 | type: 'object', 31 | properties: { 32 | // Primitive types 33 | text: { type: 'string', description: 'A string value' }, 34 | count: { type: 'integer', description: 'An integer value' }, 35 | rating: { type: 'number', description: 'A decimal number' }, 36 | enabled: { type: 'boolean', description: 'A boolean flag' }, 37 | empty: { type: 'null', description: 'A null value' }, 38 | 39 | // Array types 40 | tags: { 41 | type: 'array', 42 | items: { type: 'string' }, 43 | description: 'Array of strings' 44 | }, 45 | scores: { 46 | type: 'array', 47 | items: { type: 'number' }, 48 | description: 'Array of numbers' 49 | }, 50 | 51 | // Object types 52 | config: { 53 | type: 'object', 54 | properties: { 55 | timeout: { type: 'number' }, 56 | retries: { type: 'integer' }, 57 | debug: { type: 'boolean' } 58 | }, 59 | description: 'Configuration object' 60 | }, 61 | 62 | // Complex nested object 63 | metadata: { 64 | type: 'object', 65 | properties: { 66 | user: { 67 | type: 'object', 68 | properties: { 69 | id: { type: 'integer' }, 70 | name: { type: 'string' }, 71 | preferences: { 72 | type: 'array', 73 | items: { type: 'string' } 74 | } 75 | } 76 | }, 77 | timestamps: { 78 | type: 'array', 79 | items: { type: 'number' } 80 | } 81 | }, 82 | description: 'Complex nested metadata' 83 | } 84 | }, 85 | required: ['text', 'count'] 86 | } 87 | } 88 | ] 89 | }; 90 | }); 91 | 92 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 93 | const { name, arguments: args } = request.params; 94 | 95 | if (name === 'test_all_types') { 96 | return { 97 | content: [ 98 | { 99 | type: 'text', 100 | text: `Received arguments: ${JSON.stringify(args, null, 2)}\n\nArgument types:\n${Object.entries(args).map(([key, value]) => `${key}: ${typeof value} (${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value})`).join('\n')}` 101 | } 102 | ] 103 | }; 104 | } 105 | 106 | throw new Error(`Unknown tool: ${name}`); 107 | }); 108 | 109 | async function main() { 110 | const transport = new StdioServerTransport(); 111 | await server.connect(transport); 112 | } 113 | 114 | main().catch(console.error); -------------------------------------------------------------------------------- /tests/unit/daemon-client-ipc-timeout.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | 3 | const envSnapshot = { ...process.env }; 4 | 5 | // Mock the orchestrator to avoid touching the real platform runtime 6 | vi.mock('../../src/daemon/runtime.ts', () => { 7 | const orchestrator = { 8 | type: 'launchd', 9 | computeId: () => 'test-id', 10 | async ensure(_cmd: string, _args: string[], _opts: unknown) { 11 | return { id: 'test-id', socketPath: '/tmp/fake.sock', updateAction: 'unchanged' }; 12 | }, 13 | async stop() {}, 14 | async status() { return []; }, 15 | async clean() {}, 16 | }; 17 | return { 18 | resolveOrchestrator: async () => orchestrator, 19 | computeDaemonId: () => 'test-id', 20 | deriveIdentityEnv: (e: Record) => e, 21 | }; 22 | }); 23 | 24 | // Mock the IPC layer to capture the timeout value used 25 | vi.mock('../../src/daemon/ipc.ts', () => { 26 | let lastTimeout = -1; 27 | return { 28 | generateRequestId: () => 'req-1', 29 | async sendIPCRequest( 30 | _socketPath: string, 31 | _request: unknown, 32 | timeoutMs: number, 33 | _connectRetryBudgetMs?: number, 34 | _signal?: AbortSignal, 35 | ) { 36 | lastTimeout = timeoutMs; 37 | return { ok: true }; 38 | }, 39 | __getLastTimeout: () => lastTimeout, 40 | }; 41 | }); 42 | 43 | describe('DaemonClient IPC timeout hierarchy', () => { 44 | beforeEach(() => { 45 | // Reset module registry before setting env to ensure fresh imports 46 | vi.resetModules(); 47 | process.env = { ...envSnapshot }; 48 | // Simulate misconfiguration: IPC < tool timeout 49 | process.env.MCPLI_IPC_TIMEOUT = '300000'; // 5 minutes 50 | process.env.MCPLI_TOOL_TIMEOUT_MS = '700000'; // 11m > 5m 51 | }); 52 | 53 | afterEach(() => { 54 | process.env = { ...envSnapshot }; 55 | }); 56 | 57 | it('enforces IPC timeout >= tool timeout + buffer for callTool', async () => { 58 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 59 | const ipc = (await import('../../src/daemon/ipc.ts')) as unknown as { 60 | __getLastTimeout: () => number; 61 | }; 62 | 63 | const client = new DaemonClient('fake-server', [], {}); 64 | await client.callTool({ name: 'x' } as any); 65 | 66 | // Tool timeout (700000) + 60000 buffer = 760000 67 | expect(ipc.__getLastTimeout()).toBe(760000); 68 | }); 69 | 70 | it('uses configured IPC timeout for non-tool requests (listTools)', async () => { 71 | // Ensure no tool-timeout is present for this test case 72 | delete process.env.MCPLI_TOOL_TIMEOUT_MS; 73 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 74 | const ipc = (await import('../../src/daemon/ipc.ts')) as unknown as { 75 | __getLastTimeout: () => number; 76 | }; 77 | 78 | const client = new DaemonClient('fake-server', [], { ipcTimeoutMs: 300000 }); 79 | await client.listTools(); 80 | 81 | // For non-tool methods, use MCPLI_IPC_TIMEOUT env (300000) 82 | expect(ipc.__getLastTimeout()).toBe(300000); 83 | }); 84 | 85 | it('auto-buffers listTools IPC when tool timeout provided (via options.env)', async () => { 86 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 87 | const ipc = (await import('../../src/daemon/ipc.ts')) as unknown as { 88 | __getLastTimeout: () => number; 89 | }; 90 | 91 | const client = new DaemonClient('fake-server', [], { 92 | env: { MCPLI_TOOL_TIMEOUT_MS: '820000' }, 93 | }); 94 | await client.listTools(); 95 | 96 | // listTools should get buffered to toolTimeout+60s = 820000 + 60000 = 880000 97 | expect(ipc.__getLastTimeout()).toBe(880000); 98 | }); 99 | 100 | it('autobuffers based on MCPLI_TOOL_TIMEOUT_MS provided via options.env', async () => { 101 | const { DaemonClient } = await import('../../src/daemon/client.ts'); 102 | const ipc = (await import('../../src/daemon/ipc.ts')) as unknown as { 103 | __getLastTimeout: () => number; 104 | }; 105 | 106 | const client = new DaemonClient('fake-server', [], { 107 | env: { MCPLI_TOOL_TIMEOUT_MS: '820000' }, 108 | }); 109 | await client.callTool({ name: 'x' } as any); 110 | 111 | // 820000 + 60000 = 880000 112 | expect(ipc.__getLastTimeout()).toBe(880000); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Simple Test MCP Server 5 | * 6 | * A minimal MCP server with no external dependencies for testing 7 | * daemon lifecycle and IPC communication. 8 | * 9 | * Tools: 10 | * - echo: Returns the input message 11 | * - fail: Intentionally throws an error 12 | * - delay: Waits for specified duration 13 | */ 14 | 15 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 16 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 17 | import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; 18 | 19 | const server = new Server( 20 | { 21 | name: 'simple-test-server', 22 | version: '1.0.0', 23 | }, 24 | { 25 | capabilities: { 26 | tools: {}, 27 | }, 28 | } 29 | ); 30 | 31 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 32 | tools: [ 33 | { 34 | name: 'echo', 35 | description: 'Echo back the input message', 36 | inputSchema: { 37 | type: 'object', 38 | properties: { message: { type: 'string' } }, 39 | required: ['message'], 40 | }, 41 | }, 42 | { 43 | name: 'fail', 44 | description: 'Intentionally throw an error', 45 | inputSchema: { 46 | type: 'object', 47 | properties: { message: { type: 'string' } }, 48 | }, 49 | }, 50 | { 51 | name: 'delay', 52 | description: 'Wait for duration_ms milliseconds (honors cancellation)', 53 | inputSchema: { 54 | type: 'object', 55 | properties: { duration_ms: { type: 'number' } }, 56 | required: ['duration_ms'], 57 | }, 58 | }, 59 | { 60 | name: 'sleep', 61 | description: 'Sleep for N seconds (honors cancellation)', 62 | inputSchema: { 63 | type: 'object', 64 | properties: { seconds: { type: 'number' } }, 65 | required: ['seconds'], 66 | }, 67 | }, 68 | ], 69 | })); 70 | 71 | function delayWithAbort(ms, signal) { 72 | return new Promise((resolve, reject) => { 73 | const t = setTimeout(resolve, ms); 74 | const onAbort = () => { 75 | clearTimeout(t); 76 | reject(new Error('aborted')); 77 | }; 78 | if (signal?.aborted) return onAbort(); 79 | signal?.addEventListener?.('abort', onAbort, { once: true }); 80 | }); 81 | } 82 | 83 | server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { 84 | const { name, arguments: argsRaw } = request.params ?? {}; 85 | const args = argsRaw ?? {}; 86 | 87 | if (name === 'echo') { 88 | if (typeof args.message !== 'string') throw new Error('echo.message must be a string'); 89 | console.error(`[TOOL] echo: ${args.message}`); 90 | return { content: [{ type: 'text', text: args.message }] }; 91 | } 92 | 93 | if (name === 'fail') { 94 | console.error(`[TOOL] fail: ${args.message ?? 'no message'}`); 95 | throw new Error(typeof args.message === 'string' ? args.message : 'This is an intentional failure.'); 96 | } 97 | 98 | if (name === 'delay') { 99 | const ms = Number(args.duration_ms); 100 | if (!Number.isFinite(ms) || ms < 0 || ms > 300000) throw new Error('delay.duration_ms must be 0..300000'); 101 | console.error(`[TOOL] delay start: ${ms}ms`); 102 | try { 103 | await delayWithAbort(ms, extra?.signal); 104 | console.error(`[TOOL] delay completed`); 105 | return { content: [{ type: 'text', text: `Delayed for ${ms}ms` }] }; 106 | } catch { 107 | console.error(`[TOOL] delay cancelled`); 108 | return { content: [{ type: 'text', text: 'Cancelled' }], isError: true }; 109 | } 110 | } 111 | 112 | if (name === 'sleep') { 113 | const secs = Number(args.seconds); 114 | if (!Number.isFinite(secs) || secs < 0 || secs > 3600) throw new Error('sleep.seconds must be 0..3600'); 115 | const ms = Math.trunc(secs * 1000); 116 | console.error(`[TOOL] sleep start: ${secs}s`); 117 | try { 118 | await delayWithAbort(ms, extra?.signal); 119 | console.error(`[TOOL] sleep completed`); 120 | return { content: [{ type: 'text', text: `Slept ${secs}s` }] }; 121 | } catch { 122 | console.error(`[TOOL] sleep cancelled`); 123 | return { content: [{ type: 'text', text: 'Cancelled' }], isError: true }; 124 | } 125 | } 126 | 127 | console.error(`[TOOL] unknown tool: ${name}`); 128 | throw new Error(`Unknown tool: ${name}`); 129 | }); 130 | 131 | async function main() { 132 | const transport = new StdioServerTransport(); 133 | await server.connect(transport); 134 | console.error('[TEST] server running'); 135 | } 136 | 137 | main().catch(console.error); 138 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import prettierPlugin from 'eslint-plugin-prettier'; 4 | 5 | export default [ 6 | eslint.configs.recommended, 7 | ...tseslint.configs.recommended, 8 | { 9 | ignores: ['node_modules/**', 'dist/**', 'coverage/**', 'src/mcpli-backup.ts'], 10 | }, 11 | { 12 | // TypeScript files in src/ directory (covered by tsconfig.json) 13 | files: ['src/**/*.ts'], 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | sourceType: 'module', 17 | parser: tseslint.parser, 18 | parserOptions: { 19 | project: ['./tsconfig.json'], 20 | }, 21 | }, 22 | plugins: { 23 | '@typescript-eslint': tseslint.plugin, 24 | 'prettier': prettierPlugin, 25 | }, 26 | rules: { 27 | 'prettier/prettier': 'error', 28 | '@typescript-eslint/explicit-function-return-type': 'warn', 29 | '@typescript-eslint/no-explicit-any': 'error', 30 | '@typescript-eslint/no-unused-vars': ['error', { 31 | argsIgnorePattern: 'never', 32 | varsIgnorePattern: 'never' 33 | }], 34 | 35 | // Prevent dangerous type casting anti-patterns (errors) 36 | '@typescript-eslint/consistent-type-assertions': ['error', { 37 | assertionStyle: 'as', 38 | objectLiteralTypeAssertions: 'never' 39 | }], 40 | '@typescript-eslint/no-unsafe-argument': 'error', 41 | '@typescript-eslint/no-unsafe-assignment': 'error', 42 | '@typescript-eslint/no-unsafe-call': 'error', 43 | '@typescript-eslint/no-unsafe-member-access': 'error', 44 | '@typescript-eslint/no-unsafe-return': 'error', 45 | 46 | // Prevent specific anti-patterns we found 47 | '@typescript-eslint/ban-ts-comment': ['error', { 48 | 'ts-expect-error': 'allow-with-description', 49 | 'ts-ignore': true, 50 | 'ts-nocheck': true, 51 | 'ts-check': false, 52 | }], 53 | 54 | // Encourage best practices (warnings - can be gradually fixed) 55 | '@typescript-eslint/prefer-as-const': 'warn', 56 | '@typescript-eslint/prefer-nullish-coalescing': 'warn', 57 | '@typescript-eslint/prefer-optional-chain': 'warn', 58 | 59 | // Prevent .js imports in TypeScript files 60 | 'no-restricted-imports': ['error', { 61 | patterns: [ 62 | { 63 | group: ['./**/*.js', '../**/*.js'], 64 | message: 'Import TypeScript files with .ts extension, not .js. This ensures compatibility with native TypeScript runtimes like Bun and Deno. Change .js to .ts in your import path.' 65 | } 66 | ] 67 | }], 68 | }, 69 | }, 70 | { 71 | // JavaScript and TypeScript files outside the main project (scripts/, etc.) 72 | files: ['**/*.{js,ts}'], 73 | ignores: ['src/**/*', '**/*.test.ts'], 74 | languageOptions: { 75 | ecmaVersion: 2020, 76 | sourceType: 'module', 77 | parser: tseslint.parser, 78 | // No project reference for scripts - use standalone parsing 79 | }, 80 | plugins: { 81 | '@typescript-eslint': tseslint.plugin, 82 | 'prettier': prettierPlugin, 83 | }, 84 | rules: { 85 | 'prettier/prettier': 'error', 86 | // Relaxed TypeScript rules for scripts since they're not in the main project 87 | '@typescript-eslint/explicit-function-return-type': 'off', 88 | '@typescript-eslint/no-explicit-any': 'warn', 89 | '@typescript-eslint/no-unused-vars': ['warn', { 90 | argsIgnorePattern: 'never', 91 | varsIgnorePattern: 'never' 92 | }], 93 | 94 | // Disable project-dependent rules for scripts 95 | '@typescript-eslint/no-unsafe-argument': 'off', 96 | '@typescript-eslint/no-unsafe-assignment': 'off', 97 | '@typescript-eslint/no-unsafe-call': 'off', 98 | '@typescript-eslint/no-unsafe-member-access': 'off', 99 | '@typescript-eslint/no-unsafe-return': 'off', 100 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 101 | '@typescript-eslint/prefer-optional-chain': 'off', 102 | }, 103 | }, 104 | { 105 | files: ['**/*.test.ts'], 106 | languageOptions: { 107 | parser: tseslint.parser, 108 | parserOptions: { 109 | project: './tsconfig.test.json', 110 | }, 111 | }, 112 | rules: { 113 | '@typescript-eslint/no-explicit-any': 'off', 114 | '@typescript-eslint/no-unused-vars': 'off', 115 | '@typescript-eslint/explicit-function-return-type': 'off', 116 | 'prefer-const': 'off', 117 | 118 | // Relax unsafe rules for tests - tests often need more flexibility 119 | '@typescript-eslint/no-unsafe-argument': 'off', 120 | '@typescript-eslint/no-unsafe-assignment': 'off', 121 | '@typescript-eslint/no-unsafe-call': 'off', 122 | '@typescript-eslint/no-unsafe-member-access': 'off', 123 | '@typescript-eslint/no-unsafe-return': 'off', 124 | }, 125 | }, 126 | ]; -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Test version (e.g., 1.0.1-test)' 11 | required: true 12 | type: string 13 | 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | jobs: 19 | release: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '24' 32 | registry-url: 'https://registry.npmjs.org' 33 | cache: 'npm' 34 | 35 | - name: Install dependencies 36 | run: npm ci 37 | 38 | - name: Build TypeScript 39 | run: npm run build 40 | 41 | - name: Lint 42 | run: npm run lint 43 | 44 | - name: Type check 45 | run: npm run typecheck 46 | 47 | - name: Run tests 48 | run: npm test 49 | 50 | - name: Get version from tag or input 51 | id: get_version 52 | run: | 53 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 54 | VERSION="${{ github.event.inputs.version }}" 55 | echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" 56 | echo "IS_TEST=true" >> "$GITHUB_OUTPUT" 57 | echo "📝 Test version: ${VERSION}" 58 | # Update package.json version for test releases only 59 | npm version "${VERSION}" --no-git-tag-version 60 | else 61 | VERSION=${GITHUB_REF#refs/tags/v} 62 | echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" 63 | echo "IS_TEST=false" >> "$GITHUB_OUTPUT" 64 | echo "🚀 Release version: ${VERSION}" 65 | # Verify package.json version matches tag 66 | PACKAGE_VERSION=$(node -p "require('./package.json').version") 67 | if [ "$PACKAGE_VERSION" != "$VERSION" ]; then 68 | echo "❌ Error: package.json version ($PACKAGE_VERSION) does not match tag version ($VERSION)." 69 | exit 1 70 | fi 71 | echo "✅ package.json version matches tag." 72 | fi 73 | 74 | - name: Create package 75 | id: pack 76 | run: | 77 | FILE=$(npm pack --json | jq -r '.[0].filename') 78 | echo "TARBALL=${FILE}" >> "$GITHUB_OUTPUT" 79 | 80 | - name: Test publish (dry run for manual triggers) 81 | if: github.event_name == 'workflow_dispatch' 82 | run: | 83 | echo "🧪 Testing package creation (dry run)" 84 | npm publish "${{ steps.pack.outputs.TARBALL }}" --dry-run --access public 85 | 86 | - name: Publish to NPM (production releases only) 87 | if: github.event_name == 'push' 88 | run: | 89 | VERSION="${{ steps.get_version.outputs.VERSION }}" 90 | # Determine the appropriate npm tag based on version 91 | if [[ "$VERSION" == *"-beta"* ]]; then 92 | NPM_TAG="beta" 93 | elif [[ "$VERSION" == *"-alpha"* ]]; then 94 | NPM_TAG="alpha" 95 | elif [[ "$VERSION" == *"-rc"* ]]; then 96 | NPM_TAG="rc" 97 | else 98 | # For stable releases, explicitly use latest tag 99 | NPM_TAG="latest" 100 | fi 101 | echo "📦 Publishing to NPM with tag: $NPM_TAG" 102 | # Publish the exact tarball created earlier with provenance 103 | npm publish "${{ steps.pack.outputs.TARBALL }}" --access public --tag "$NPM_TAG" --provenance 104 | env: 105 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | 108 | - name: Create GitHub Release (production releases only) 109 | if: github.event_name == 'push' 110 | uses: softprops/action-gh-release@v2 111 | with: 112 | tag_name: v${{ steps.get_version.outputs.VERSION }} 113 | name: Release v${{ steps.get_version.outputs.VERSION }} 114 | body: | 115 | ## Release v${{ steps.get_version.outputs.VERSION }} 116 | 117 | Transform any stdio-based MCP server into a first‑class CLI tool. 118 | 119 | ### Features 120 | - Zero setup – Point at any MCP server, get instant CLI access 121 | - Persistent daemon for stateful operations 122 | - Natural CLI syntax with auto-generated help 123 | - Standard shell composition with pipes and output redirection 124 | - Flexible parameter syntax supporting all MCP data types 125 | 126 | ### Installation 127 | ```bash 128 | npm install -g mcpli@${{ steps.get_version.outputs.VERSION }} 129 | ``` 130 | 131 | Or use with npx: 132 | ```bash 133 | npx mcpli@${{ steps.get_version.outputs.VERSION }} -- 134 | ``` 135 | 136 | 📦 **NPM Package**: https://www.npmjs.com/package/mcpli/v/${{ steps.get_version.outputs.VERSION }} 137 | 138 | ### What's New 139 | - Browse the [changelog](https://github.com/cameroncooke/mcpli/blob/main/CHANGELOG.md) for detailed changes 140 | - Full MCP server compatibility with stdio transport 141 | - Enhanced CLI ergonomics and discoverability 142 | files: ${{ steps.pack.outputs.TARBALL }} 143 | draft: false 144 | prerelease: ${{ contains(steps.get_version.outputs.VERSION, '-') }} 145 | make_latest: ${{ !contains(steps.get_version.outputs.VERSION, '-') }} 146 | 147 | - name: Summary 148 | run: | 149 | if [ "${{ steps.get_version.outputs.IS_TEST }}" = "true" ]; then 150 | echo "🧪 Test completed for version: ${{ steps.get_version.outputs.VERSION }}" 151 | echo "Ready for production release!" 152 | else 153 | echo "🎉 Production release completed!" 154 | echo "Version: ${{ steps.get_version.outputs.VERSION }}" 155 | echo "📦 NPM: https://www.npmjs.com/package/mcpli/v/${{ steps.get_version.outputs.VERSION }}" 156 | fi 157 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Overview 6 | 7 | MCPLI is a TypeScript CLI tool that transforms stdio-based MCP (Model Context Protocol) servers into first-class command-line tools. It maintains persistent daemon processes for fast, stateful tool execution using macOS `launchd` for process orchestration. 8 | 9 | ## Key Architecture Principles 10 | 11 | ### Command Structure 12 | - **Always requires explicit server command**: `mcpli [options] -- [args...]` 13 | - **No default daemon selection**: Every invocation must specify the MCP server command after `--` 14 | - **Environment-aware daemon isolation**: Different server commands, arguments, or environment variables create separate daemons 15 | 16 | ### Daemon Identity and Environment Variables 17 | **CRITICAL**: Environment variables work differently for daemon identity vs MCP server execution: 18 | 19 | - **Daemon Identity**: Only considers environment variables specified AFTER the `--` as part of the server command 20 | - `mcpli tool -- ENV_VAR=value node server.js` → daemon ID includes ENV_VAR 21 | - `ENV_VAR=value mcpli tool -- node server.js` → daemon ID does NOT include ENV_VAR (same daemon as without ENV_VAR) 22 | 23 | - **MCP Server Environment**: Environment variables before `--` affect mcpli itself, not the daemon identity 24 | - Shell environment where mcpli runs should not influence daemon uniqueness 25 | - Only explicit environment in the server command affects daemon hashing 26 | 27 | ### File Structure 28 | - **Entry point**: `src/mcpli.ts` - CLI argument parsing and tool execution 29 | - **Daemon system**: `src/daemon/` - Persistent process management using launchd 30 | - `client.ts` - Daemon client for IPC communication 31 | - `runtime.ts` - Core orchestrator interface and daemon identity logic 32 | - `runtime-launchd.ts` - macOS launchd-based orchestrator implementation 33 | - `wrapper.ts` - In-process daemon wrapper 34 | - `commands.ts` - Daemon command handling and execution 35 | - `ipc.ts` - Unix socket IPC communication 36 | - **Test servers**: `weather-server.js`, `test-server.js`, `complex-test-server.js` 37 | 38 | ## Development Commands 39 | 40 | ### Build and Development 41 | ```bash 42 | npm run build # Build with tsup 43 | npm run dev # Alias for build 44 | npm run lint # ESLint check 45 | npm run lint:fix # ESLint fix 46 | npm run typecheck # TypeScript check 47 | ``` 48 | 49 | ### Testing 50 | - **Manual testing**: Use `docs/testing.md` for comprehensive daemon system tests 51 | - **Test servers available**: 52 | - `weather-server.js` - Full-featured with API calls (get-weather, get-forecast) 53 | - `test-server.js` - Simple/reliable (echo, fail, delay tools) 54 | - `complex-test-server.js` - JSON Schema validation testing (test_all_types) 55 | 56 | ### Running from Source 57 | ```bash 58 | # During development (from repo root) 59 | node dist/mcpli.js [options] -- 60 | 61 | # Or with ts-node 62 | npx ts-node src/mcpli.ts [options] -- 63 | ``` 64 | 65 | ## Code Quality Standards 66 | 67 | ### TypeScript Configuration 68 | - **Strict mode enabled** with `no-explicit-any`, `no-unsafe-*` rules 69 | - **ESM modules only** (`"type": "module"` in package.json) 70 | - **Node.js 18+ required** for native fetch and ESM support 71 | 72 | ### Linting Rules 73 | - Uses `@typescript-eslint` with strict rules 74 | - Prettier for code formatting 75 | - No explicit `any` types allowed 76 | - Unsafe TypeScript operations prohibited 77 | 78 | ## Architecture Details 79 | 80 | ### Daemon Lifecycle 81 | 1. **Daemon Creation**: Each unique `command + args + env` combination gets its own daemon with SHA-256 hash ID 82 | 2. **Process Orchestration**: macOS `launchd` handles daemon supervision and socket activation 83 | 3. **IPC Communication**: Unix domain sockets (under `$TMPDIR/mcpli//.sock`) 84 | 4. **Automatic Cleanup**: Configurable inactivity timeout (default: 30 minutes) 85 | 86 | ### Configuration System 87 | - **Environment Variables**: `MCPLI_DEFAULT_TIMEOUT`, `MCPLI_CLI_TIMEOUT`, `MCPLI_IPC_TIMEOUT`, `MCPLI_TIMEOUT` 88 | - **Priority**: CLI args > environment variables > built-in defaults 89 | - **Timeout Units**: Seconds for CLI (user-facing), milliseconds for internal IPC 90 | 91 | ### Error Handling 92 | - **Robust Error Handling**: Provides clear error messages when daemon operations fail 93 | - **Process Recovery**: Automatic cleanup of stale processes and socket files 94 | - **User-Friendly Messages**: Clear error reporting with actionable guidance 95 | 96 | ## Important Implementation Notes 97 | 98 | ### Environment Variable Behavior 99 | The `deriveIdentityEnv()` function in `src/daemon/runtime.ts` determines which environment variables affect daemon identity. It considers only variables explicitly provided in the server command (after `--`) and ignores the ambient shell environment. MCPLI_* variables are excluded from the ambient environment but are included if explicitly passed after `--`. 100 | 101 | ### Command Parsing 102 | The argument parsing in `src/mcpli.ts` handles the complex `-- ` syntax and environment variable extraction. Pay attention to the split between mcpli arguments and server command arguments. 103 | 104 | ### macOS Implementation Details 105 | - **launchd Integration**: Uses `launchctl` for daemon lifecycle management and socket activation 106 | - **Path Normalization**: Commands and paths are normalized for consistent daemon IDs 107 | - **Socket Permissions**: Unix domain sockets use restrictive permissions (0600) 108 | - **Process Supervision**: launchd handles automatic restart and crash recovery 109 | 110 | ## Contributing Guidelines 111 | 112 | ### Making Changes 113 | 1. **Follow TypeScript strict mode** - no `any` types, handle all error cases 114 | 2. **Test daemon behavior** using the manual test suite in `docs/testing.md` 115 | 3. **Verify environment isolation** - ensure different env vars create separate daemons as expected 116 | 4. **Run all checks**: `npm run lint && npm run typecheck && npm run build` 117 | 118 | ### Testing Strategy 119 | - **Use provided test servers** instead of external dependencies 120 | - **Test daemon lifecycle** including creation, communication, and cleanup 121 | - **Verify environment variable behavior** matches architectural expectations 122 | - **Test daemon mode** for all tool operations 123 | 124 | This codebase emphasizes reliability, performance, and clean separation between CLI interface and MCP server execution while maintaining backward compatibility and robust error handling. 125 | -------------------------------------------------------------------------------- /tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import { execa, ExecaChildProcess } from 'execa'; 4 | import { fileURLToPath } from 'url'; 5 | import { createHash } from 'crypto'; 6 | import os from 'os'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | const PROJECT_ROOT = path.resolve(__dirname, '..'); 12 | const DIST_CLI = path.resolve(PROJECT_ROOT, 'dist/mcpli.js'); 13 | 14 | export interface TestContext { 15 | cwd: string; 16 | cli: (...args: string[]) => ExecaChildProcess; 17 | cleanup: () => Promise; 18 | pollForSocket: (id: string, timeout?: number) => Promise; 19 | pollForSocketPath: (socketPath: string, timeout?: number) => Promise; 20 | pollForDaemonReady: (command: string, args: string[], timeout?: number) => Promise; 21 | getSocketPath: (id: string) => string; 22 | computeId: (command: string, args: string[], env?: Record) => string; 23 | } 24 | 25 | /** 26 | * Creates an isolated test environment in a temporary directory. 27 | * This solves the test isolation and race condition issues identified in research. 28 | */ 29 | export async function createTestEnvironment(): Promise { 30 | // Create a unique temporary directory for the test 31 | const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcpli-test-')); 32 | 33 | // Copy test servers to the temp directory 34 | await fs.copyFile(path.join(PROJECT_ROOT, 'test-server.js'), path.join(tempDir, 'test-server.js')); 35 | await fs.copyFile(path.join(PROJECT_ROOT, 'complex-test-server.js'), path.join(tempDir, 'complex-test-server.js')); 36 | await fs.copyFile(path.join(PROJECT_ROOT, 'weather-server.js'), path.join(tempDir, 'weather-server.js')); 37 | 38 | // Function to execute the mcpli CLI within the temp directory 39 | const cli = (...args: string[]): ExecaChildProcess => { 40 | return execa('node', [DIST_CLI, ...args], { 41 | cwd: tempDir, 42 | reject: false, // Don't throw on non-zero exit codes 43 | timeout: 20000, // 20s timeout for CLI commands 44 | }); 45 | }; 46 | 47 | // Hash function for cwd (must match runtime-launchd.ts hashCwd) 48 | const hashCwd = (cwd: string): string => { 49 | const abs = path.resolve(cwd || process.cwd()); 50 | return createHash('sha256').update(abs).digest('hex').slice(0, 8); 51 | }; 52 | 53 | // Normalize functions matching src/daemon/runtime.ts 54 | const normalizeCommand = (command: string, args: string[] = []): { command: string; args: string[] } => { 55 | const trimmed = String(command || '').trim(); 56 | const normCommand = path.isAbsolute(trimmed) 57 | ? path.normalize(trimmed) 58 | : path.normalize(path.resolve(trimmed)); 59 | 60 | const normArgs = (Array.isArray(args) ? args : []) 61 | .map((a) => String(a ?? '').trim()) 62 | .filter((a) => a.length > 0); 63 | 64 | const normalizedCommand = 65 | process.platform === 'win32' ? normCommand.replace(/\\/g, '/').toLowerCase() : normCommand; 66 | const normalizedArgs = 67 | process.platform === 'win32' ? normArgs.map((a) => a.replace(/\\/g, '/')) : normArgs; 68 | 69 | return { command: normalizedCommand, args: normalizedArgs }; 70 | }; 71 | 72 | const normalizeEnv = (env: Record = {}): Record => { 73 | const out: Record = {}; 74 | for (const [k, v] of Object.entries(env)) { 75 | const key = process.platform === 'win32' ? k.toUpperCase() : k; 76 | out[key] = String(v ?? ''); 77 | } 78 | return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))); 79 | }; 80 | 81 | const computeId = (command: string, args: string[], env: Record = {}): string => { 82 | // Match runtime.ts computeDaemonId exactly 83 | const norm = normalizeCommand(command, args); 84 | const normEnv = normalizeEnv(env); 85 | const input = JSON.stringify([norm.command, ...norm.args, { env: normEnv }]); 86 | const digest = createHash('sha256').update(input).digest('hex'); 87 | return digest.slice(0, 8); 88 | }; 89 | 90 | // Socket path must use the same cwd scope the CLI uses (project root), not tempDir 91 | const getSocketPath = (id: string): string => { 92 | if (os.platform() !== 'darwin') { 93 | throw new Error('Socket path calculation only supported on macOS'); 94 | } 95 | const cwdHash = hashCwd(PROJECT_ROOT); // match launchd runtime scoping 96 | return path.join(os.tmpdir(), 'mcpli', cwdHash, `${id}.sock`); 97 | }; 98 | 99 | // Poll for socket file existence (addresses race condition issues) 100 | const pollForSocket = async (id: string, timeout = 10000): Promise => { 101 | const socketPath = getSocketPath(id); 102 | return pollForSocketPath(socketPath, timeout); 103 | }; 104 | 105 | // Poll for socket file existence by direct path 106 | const pollForSocketPath = async (socketPath: string, timeout = 10000): Promise => { 107 | const start = Date.now(); 108 | 109 | while (Date.now() - start < timeout) { 110 | try { 111 | const stats = await fs.stat(socketPath); 112 | if (stats.isSocket()) { 113 | return socketPath; 114 | } 115 | } catch { 116 | // Socket doesn't exist yet, continue polling 117 | } 118 | await new Promise(resolve => setTimeout(resolve, 100)); 119 | } 120 | 121 | throw new Error(`Socket ${socketPath} not ready after ${timeout}ms`); 122 | }; 123 | 124 | // Poll for daemon to be ready for communication (addresses timing issues) 125 | const pollForDaemonReady = async (command: string, args: string[], timeout = 15000): Promise => { 126 | const start = Date.now(); 127 | 128 | while (Date.now() - start < timeout) { 129 | try { 130 | const result = await cli('--help', '--', command, ...args); 131 | if (result.exitCode === 0 && result.stdout.includes('Available Tools:')) { 132 | return; // Daemon is ready and responding 133 | } 134 | } catch { 135 | // Not ready yet, continue polling 136 | } 137 | await new Promise(resolve => setTimeout(resolve, 200)); 138 | } 139 | 140 | throw new Error(`Daemon not ready after ${timeout}ms`); 141 | }; 142 | 143 | // Cleanup function 144 | const cleanup = async (): Promise => { 145 | try { 146 | // Clean all daemons in this temp directory 147 | await cli('daemon', 'clean'); 148 | 149 | // Remove temp directory 150 | await fs.rm(tempDir, { recursive: true, force: true }); 151 | } catch (error) { 152 | console.warn(`Cleanup warning: ${error}`); 153 | } 154 | }; 155 | 156 | return { 157 | cwd: tempDir, 158 | cli, 159 | cleanup, 160 | pollForSocket, 161 | pollForSocketPath, 162 | pollForDaemonReady, 163 | getSocketPath, 164 | computeId 165 | }; 166 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! Here's how you can help improve MCPLI. 4 | 5 | ## Local development setup 6 | 7 | ### Prerequisites 8 | 9 | - Node.js (v18 or later) 10 | - npm 11 | - Git 12 | 13 | ### Installation 14 | 15 | 1. Clone the repository: 16 | ```bash 17 | git clone https://github.com/cameroncooke/mcpli.git 18 | cd mcpli 19 | ``` 20 | 21 | 2. Install dependencies: 22 | ```bash 23 | npm install 24 | ``` 25 | 26 | 3. Build the project: 27 | ```bash 28 | npm run build 29 | ``` 30 | 31 | 4. Test the CLI: 32 | ```bash 33 | ./dist/mcpli.js --help -- node weather-server.js 34 | ``` 35 | 36 | ### Development Workflow 37 | 38 | #### Building 39 | ```bash 40 | # Build once 41 | npm run build 42 | 43 | # Build and copy files 44 | npm run dev 45 | ``` 46 | 47 | #### Testing with MCP Servers 48 | 49 | The repository includes a sample weather server for testing: 50 | 51 | ```bash 52 | # Test help output 53 | ./dist/mcpli.js --help -- node weather-server.js 54 | 55 | # Test tool-specific help 56 | ./dist/mcpli.js get-weather --help -- node weather-server.js 57 | 58 | # Test tool execution 59 | ./dist/mcpli.js get-weather --location "San Francisco" -- node weather-server.js 60 | 61 | # Test with pipes 62 | ./dist/mcpli.js get-weather --location "NYC" -- node weather-server.js | jq -r '.temperature' 63 | ``` 64 | 65 | #### Daemon Development 66 | 67 | Test the daemon functionality: 68 | 69 | ```bash 70 | # Start daemon explicitly 71 | ./dist/mcpli.js daemon start -- node weather-server.js 72 | 73 | # Check daemon status 74 | ./dist/mcpli.js daemon status 75 | 76 | # View daemon logs 77 | ./dist/mcpli.js daemon logs 78 | 79 | # Stop daemon 80 | ./dist/mcpli.js daemon stop -- node weather-server.js 81 | ``` 82 | 83 | #### Testing Different Configurations 84 | 85 | Test with various server configurations: 86 | 87 | ```bash 88 | # Test with environment variables 89 | ./dist/mcpli.js get-weather --location "London" -- API_KEY=test node weather-server.js 90 | 91 | # Test with server arguments 92 | ./dist/mcpli.js get-weather --location "Tokyo" -- node weather-server.js --debug 93 | 94 | # Test timeout configurations 95 | ./dist/mcpli.js get-weather --timeout=60 --location "Paris" -- node weather-server.js 96 | ``` 97 | 98 | ## Architecture Overview 99 | 100 | MCPLI consists of several key components: 101 | 102 | ### Core Components 103 | 104 | - **`src/mcpli.ts`** - Main CLI entry point and command parsing 105 | - **`src/daemon/`** - Daemon process management using macOS launchd 106 | - `runtime.ts` - Core orchestrator interface and daemon identity logic 107 | - `runtime-launchd.ts` - macOS launchd-based orchestrator implementation 108 | - `client.ts` - Daemon client for IPC communication 109 | - `commands.ts` - Daemon command handling and execution 110 | - `wrapper.ts` - In-process daemon wrapper 111 | - `ipc.ts` - Unix socket IPC communication 112 | - **`src/config.ts`** - Configuration management and environment variables 113 | 114 | ### Key Features 115 | 116 | - **Daemon Management**: Persistent processes with hash-based identity using launchd supervision 117 | - **Process Orchestration**: macOS launchd handles daemon lifecycle and socket activation 118 | - **IPC Communication**: Unix sockets for client-daemon communication 119 | - **Parameter Parsing**: JSON Schema-aware CLI argument parsing 120 | - **Help Generation**: Dynamic help from MCP tool schemas 121 | - **Timeout Management**: Configurable daemon and operation timeouts 122 | 123 | ## Code Standards 124 | 125 | ### TypeScript Guidelines 126 | 127 | 1. **Strict typing** - No `any` types, use proper interfaces 128 | 2. **Error handling** - Always handle errors gracefully 129 | 3. **Logging** - Use consistent error and debug logging 130 | 4. **Documentation** - Document public interfaces and complex logic 131 | 132 | ### Code Style 133 | 134 | 1. **Follow existing patterns** in the codebase 135 | 2. **Use descriptive names** for functions and variables 136 | 3. **Keep functions focused** - single responsibility principle 137 | 4. **Add JSDoc comments** for public APIs 138 | 139 | ### Testing Standards 140 | 141 | When tests are added (future enhancement): 142 | 143 | 1. Test daemon mode functionality 144 | 2. Test various parameter types and edge cases 145 | 3. Test timeout behaviors 146 | 4. Test daemon isolation with different configurations 147 | 5. Test error handling and recovery 148 | 149 | ## Making Changes 150 | 151 | ### Before You Start 152 | 153 | 1. **Check existing issues** - See if your idea is already being discussed 154 | 2. **Open an issue** for significant changes to discuss approach 155 | 3. **Fork the repository** and create a feature branch 156 | 157 | ### Development Process 158 | 159 | 1. **Create a feature branch** from main: 160 | ```bash 161 | git checkout -b feature/your-feature-name 162 | ``` 163 | 164 | 2. **Make your changes** following the code standards 165 | 166 | 3. **Test thoroughly**: 167 | ```bash 168 | # Build and test 169 | npm run build 170 | npm run typecheck 171 | npm run lint 172 | 173 | # Manual testing with various MCP servers 174 | ./dist/mcpli.js --help -- node weather-server.js 175 | ``` 176 | 177 | 4. **Update documentation** if you've added features or changed behavior 178 | 179 | 5. **Commit with clear messages**: 180 | ```bash 181 | git add . 182 | git commit -m "feat: add support for XYZ feature" 183 | ``` 184 | 185 | ### Pull Request Guidelines 186 | 187 | 1. **Clear description** of what the PR does and why 188 | 2. **Link related issues** using keywords (closes #123) 189 | 3. **Include testing instructions** for reviewers 190 | 4. **Keep changes focused** - one feature/fix per PR 191 | 5. **Update CHANGELOG.md** with your changes 192 | 193 | ### Commit Message Format 194 | 195 | Use conventional commits format: 196 | 197 | - `feat:` - New features 198 | - `fix:` - Bug fixes 199 | - `docs:` - Documentation changes 200 | - `refactor:` - Code refactoring 201 | - `test:` - Adding tests 202 | - `chore:` - Build/tooling changesxw 203 | 204 | ## Code Quality Checklist 205 | 206 | Before submitting a PR, ensure: 207 | 208 | - [ ] Code builds without errors: `npm run build` 209 | - [ ] TypeScript checks pass: `npm run typecheck` 210 | - [ ] Linting passes: `npm run lint` 211 | - [ ] Manual testing with sample server works 212 | - [ ] Documentation updated for new features 213 | - [ ] CHANGELOG.md updated 214 | - [ ] Commit messages follow convention 215 | - [ ] PR description is clear and complete 216 | 217 | ## Getting Help 218 | 219 | - **Issues**: Use GitHub issues for bugs and feature requests 220 | - **Discussions**: Use GitHub discussions for questions 221 | - **Code Review**: Maintainers will review PRs and provide feedback 222 | 223 | ## Code of Conduct 224 | 225 | Please be respectful and constructive in all interactions. We want MCPLI to have a welcoming community for all contributors. 226 | 227 | Key principles: 228 | - **Be respectful** of different opinions and approaches 229 | - **Be constructive** in feedback and suggestions 230 | - **Be collaborative** - we're all working toward the same goal 231 | - **Be patient** with new contributors and questions 232 | 233 | Thank you for contributing to MCPLI! 🚀 -------------------------------------------------------------------------------- /scripts/test-regression.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # MCPLI Regression Test Script 5 | # Tests core functionality to catch regressions after each commit 6 | 7 | MCPLI="node dist/mcpli.js" 8 | TEST_SERVER="node test-server.js" 9 | WEATHER_SERVER="OPENAI_API_KEY=test node weather-server.js" 10 | 11 | echo "🧪 MCPLI Regression Test Suite" 12 | echo "==============================" 13 | 14 | # Function to run test and capture result 15 | run_test() { 16 | local test_name="$1" 17 | local command="$2" 18 | local expected_pattern="$3" 19 | 20 | echo -n "Testing $test_name... " 21 | 22 | # Run command and capture output 23 | if output=$(eval "$command" 2>&1); then 24 | # Check if output matches expected pattern 25 | if echo "$output" | grep -q "$expected_pattern"; then 26 | echo "✅ PASS" 27 | return 0 28 | else 29 | echo "❌ FAIL - Output didn't match expected pattern" 30 | echo "Expected pattern: $expected_pattern" 31 | echo "Actual output: $output" 32 | return 1 33 | fi 34 | else 35 | echo "❌ FAIL - Command failed" 36 | echo "Output: $output" 37 | return 1 38 | fi 39 | } 40 | 41 | # Function to ensure completely clean environment 42 | ensure_clean_environment() { 43 | echo "" 44 | echo "🧹 Environment Cleanup" 45 | echo "---------------------" 46 | 47 | # Clean daemon state via CLI 48 | echo -n "Cleaning daemon state... " 49 | $MCPLI daemon clean >/dev/null 2>&1 || true 50 | echo "✅ DONE" 51 | 52 | # Kill any leftover mcpli processes 53 | echo -n "Killing leftover processes... " 54 | pkill -f "node.*mcpli" >/dev/null 2>&1 || true 55 | pkill -f "mcpli.*daemon" >/dev/null 2>&1 || true 56 | pkill -f "wrapper\.js" >/dev/null 2>&1 || true 57 | sleep 0.5 # Give processes time to die 58 | echo "✅ DONE" 59 | 60 | # Remove temporary files and directories 61 | echo -n "Cleaning temporary files... " 62 | # Remove .mcpli directories in common locations 63 | rm -rf ./.mcpli >/dev/null 2>&1 || true 64 | rm -rf ~/.mcpli >/dev/null 2>&1 || true 65 | rm -rf /tmp/.mcpli* >/dev/null 2>&1 || true 66 | rm -rf /var/folders/*/*/*mcpli* >/dev/null 2>&1 || true 67 | echo "✅ DONE" 68 | 69 | # Clean up any launchd services (best effort, with timeout) 70 | echo -n "Cleaning launchd services... " 71 | timeout 5s bash -c ' 72 | launchctl list 2>/dev/null | grep "com.mcpli" | awk "{print \$3}" | while read -r label; do 73 | launchctl unload "$label" >/dev/null 2>&1 || true 74 | launchctl remove "$label" >/dev/null 2>&1 || true 75 | done 76 | ' >/dev/null 2>&1 || true 77 | echo "✅ DONE" 78 | 79 | # Verify no mcpli processes remain 80 | echo -n "Verifying clean process state... " 81 | if pgrep -f "(node.*mcpli|mcpli.*daemon|wrapper\.js)" >/dev/null 2>&1; then 82 | echo "⚠️ Warning: MCPLI processes still running:" 83 | pgrep -lf "(node.*mcpli|mcpli.*daemon|wrapper\.js)" || true 84 | else 85 | echo "✅ PASS" 86 | fi 87 | } 88 | 89 | # Function to test daemon operations 90 | test_daemon_ops() { 91 | echo "" 92 | echo "🔧 Testing Daemon Operations" 93 | echo "----------------------------" 94 | 95 | # Test daemon status (should show no daemons) 96 | echo -n "Checking clean state... " 97 | if output=$($MCPLI daemon status 2>&1); then 98 | if echo "$output" | grep -qE "(No running daemons found|No daemons found)"; then 99 | echo "✅ PASS" 100 | else 101 | echo "❌ FAIL - Expected no daemons, got: $output" 102 | return 1 103 | fi 104 | else 105 | echo "❌ FAIL - daemon status command failed" 106 | return 1 107 | fi 108 | } 109 | 110 | # Function to test basic tool calls 111 | test_tool_calls() { 112 | echo "" 113 | echo "🛠️ Testing Tool Calls" 114 | echo "---------------------" 115 | 116 | # Test echo tool 117 | run_test "echo tool" \ 118 | "$MCPLI echo --message \"test message\" -- $TEST_SERVER" \ 119 | "test message" 120 | 121 | # Test delay tool with new validation 122 | run_test "delay tool" \ 123 | "$MCPLI delay --duration_ms 100 -- $TEST_SERVER" \ 124 | "Delayed for 100ms" 125 | 126 | # Test weather tool (basic functionality, not API call) 127 | run_test "weather server startup" \ 128 | "$MCPLI get-weather --location \"Berlin\" -- $WEATHER_SERVER" \ 129 | "Berlin" 130 | } 131 | 132 | # Function to test daemon persistence 133 | test_daemon_persistence() { 134 | echo "" 135 | echo "🔄 Testing Daemon Persistence" 136 | echo "-----------------------------" 137 | 138 | # Make a call to create daemon 139 | echo -n "Creating daemon... " 140 | if $MCPLI echo --message "create daemon" -- $TEST_SERVER >/dev/null 2>&1; then 141 | echo "✅ PASS" 142 | else 143 | echo "❌ FAIL" 144 | return 1 145 | fi 146 | 147 | # Check daemon is running 148 | echo -n "Verifying daemon running... " 149 | if output=$($MCPLI daemon status 2>&1); then 150 | if echo "$output" | grep -q "Running: yes"; then 151 | echo "✅ PASS" 152 | else 153 | echo "❌ FAIL - Expected running daemon, got: $output" 154 | return 1 155 | fi 156 | else 157 | echo "❌ FAIL - daemon status failed" 158 | return 1 159 | fi 160 | 161 | # Make another call to same daemon (should reuse) 162 | run_test "daemon reuse" \ 163 | "$MCPLI echo --message \"reuse daemon\" -- $TEST_SERVER" \ 164 | "reuse daemon" 165 | } 166 | 167 | # Function to test error handling 168 | test_error_handling() { 169 | echo "" 170 | echo "⚠️ Testing Error Handling" 171 | echo "-------------------------" 172 | 173 | # Test invalid tool 174 | echo -n "Testing invalid tool... " 175 | if output=$($MCPLI nonexistent-tool -- $TEST_SERVER 2>&1); then 176 | echo "❌ FAIL - Should have failed" 177 | return 1 178 | else 179 | if echo "$output" | grep -qE "(Unknown tool|tool not found)"; then 180 | echo "✅ PASS" 181 | else 182 | echo "❌ FAIL - Wrong error message: $output" 183 | return 1 184 | fi 185 | fi 186 | 187 | # Test delay tool validation (should reject >60000ms) 188 | echo -n "Testing delay validation... " 189 | if output=$($MCPLI delay --duration_ms 70000 -- $TEST_SERVER 2>&1); then 190 | echo "❌ FAIL - Should have rejected long delay" 191 | return 1 192 | else 193 | if echo "$output" | grep -q "between 0 and 60000"; then 194 | echo "✅ PASS" 195 | else 196 | echo "❌ FAIL - Wrong validation message: $output" 197 | return 1 198 | fi 199 | fi 200 | } 201 | 202 | # Function to test build artifacts 203 | test_build_artifacts() { 204 | echo "" 205 | echo "📦 Testing Build Artifacts" 206 | echo "--------------------------" 207 | 208 | # Check main binary exists and is executable 209 | echo -n "Checking mcpli.js executable... " 210 | if [[ -f "dist/mcpli.js" && -x "dist/mcpli.js" ]]; then 211 | echo "✅ PASS" 212 | else 213 | echo "❌ FAIL - dist/mcpli.js missing or not executable" 214 | return 1 215 | fi 216 | 217 | # Check daemon wrapper exists and is executable 218 | echo -n "Checking wrapper.js executable... " 219 | if [[ -f "dist/daemon/wrapper.js" && -x "dist/daemon/wrapper.js" ]]; then 220 | echo "✅ PASS" 221 | else 222 | echo "❌ FAIL - dist/daemon/wrapper.js missing or not executable" 223 | return 1 224 | fi 225 | } 226 | 227 | # Thorough cleanup function 228 | thorough_cleanup() { 229 | echo "" 230 | echo "🧹 Final Cleanup" 231 | echo "---------------" 232 | 233 | echo -n "Daemon cleanup... " 234 | $MCPLI daemon clean >/dev/null 2>&1 || true 235 | echo "✅ DONE" 236 | 237 | echo -n "Process cleanup... " 238 | pkill -f "node.*mcpli" >/dev/null 2>&1 || true 239 | pkill -f "mcpli.*daemon" >/dev/null 2>&1 || true 240 | pkill -f "wrapper\.js" >/dev/null 2>&1 || true 241 | echo "✅ DONE" 242 | 243 | echo -n "File cleanup... " 244 | rm -rf ./.mcpli >/dev/null 2>&1 || true 245 | echo "✅ DONE" 246 | } 247 | 248 | # Main test execution 249 | main() { 250 | local failed=0 251 | 252 | # Ensure we're in the right directory 253 | if [[ ! -f "package.json" || ! -f "dist/mcpli.js" ]]; then 254 | echo "❌ Error: Must run from project root with built artifacts" 255 | echo " Run 'npm run build' first" 256 | exit 1 257 | fi 258 | 259 | # Clean environment before testing 260 | ensure_clean_environment 261 | 262 | # Run all test suites 263 | test_build_artifacts || failed=1 264 | test_daemon_ops || failed=1 265 | test_tool_calls || failed=1 266 | test_daemon_persistence || failed=1 267 | test_error_handling || failed=1 268 | 269 | # Final cleanup 270 | thorough_cleanup 271 | 272 | # Summary 273 | echo "" 274 | echo "📊 Test Results" 275 | echo "===============" 276 | if [[ $failed -eq 0 ]]; then 277 | echo "🎉 ALL TESTS PASSED - No regressions detected!" 278 | exit 0 279 | else 280 | echo "💥 TESTS FAILED - Regressions detected!" 281 | exit 1 282 | fi 283 | } 284 | 285 | # Handle script interruption 286 | cleanup_on_interrupt() { 287 | echo "" 288 | echo "⏹️ Test interrupted - cleaning up..." 289 | pkill -f "node.*mcpli" >/dev/null 2>&1 || true 290 | pkill -f "mcpli.*daemon" >/dev/null 2>&1 || true 291 | pkill -f "wrapper\.js" >/dev/null 2>&1 || true 292 | $MCPLI daemon clean >/dev/null 2>&1 || true 293 | rm -rf ./.mcpli >/dev/null 2>&1 || true 294 | exit 1 295 | } 296 | trap cleanup_on_interrupt INT TERM 297 | 298 | # Run main function 299 | main "$@" -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # GitHub Release Creation Script 5 | # This script handles only the GitHub release creation. 6 | # Building and NPM publishing are handled by GitHub workflows. 7 | # 8 | # Usage: ./scripts/release.sh [VERSION|BUMP_TYPE] [OPTIONS] 9 | # Run with --help for detailed usage information 10 | FIRST_ARG=$1 11 | DRY_RUN=false 12 | VERSION="" 13 | BUMP_TYPE="" 14 | 15 | # Function to show help 16 | show_help() { 17 | cat << 'EOF' 18 | 📦 GitHub Release Creator for MCPLI 19 | 20 | Creates releases with automatic semver bumping. Only handles GitHub release 21 | creation - building and NPM publishing are handled by workflows. 22 | 23 | USAGE: 24 | [VERSION|BUMP_TYPE] [OPTIONS] 25 | 26 | ARGUMENTS: 27 | VERSION Explicit version (e.g., 1.5.0, 2.0.0-beta.1) 28 | BUMP_TYPE major | minor [default] | patch 29 | 30 | OPTIONS: 31 | --dry-run Preview without executing 32 | -h, --help Show this help 33 | 34 | EXAMPLES: 35 | (no args) Interactive minor bump 36 | major Interactive major bump 37 | 1.5.0 Use specific version 38 | patch --dry-run Preview patch bump 39 | 40 | EOF 41 | 42 | local highest_version=$(get_highest_version) 43 | if [[ -n "$highest_version" ]]; then 44 | echo "CURRENT: $highest_version" 45 | echo "NEXT: major=$(bump_version "$highest_version" "major") | minor=$(bump_version "$highest_version" "minor") | patch=$(bump_version "$highest_version" "patch")" 46 | else 47 | echo "No existing version tags found" 48 | fi 49 | echo "" 50 | } 51 | 52 | # Function to get the highest version from git tags 53 | get_highest_version() { 54 | git tag | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?$' | sed 's/^v//' | sort -V | tail -1 55 | } 56 | 57 | # Function to parse version components 58 | parse_version() { 59 | local version=$1 60 | echo "$version" | sed -E 's/^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$/\1 \2 \3 \4/' 61 | } 62 | 63 | # Function to bump version based on type 64 | bump_version() { 65 | local current_version=$1 66 | local bump_type=$2 67 | 68 | local parsed=($(parse_version "$current_version")) 69 | local major=${parsed[0]} 70 | local minor=${parsed[1]} 71 | local patch=${parsed[2]} 72 | local prerelease=${parsed[3]:-""} 73 | 74 | # Remove prerelease for stable version bumps 75 | case $bump_type in 76 | major) 77 | echo "$((major + 1)).0.0" 78 | ;; 79 | minor) 80 | echo "${major}.$((minor + 1)).0" 81 | ;; 82 | patch) 83 | echo "${major}.${minor}.$((patch + 1))" 84 | ;; 85 | *) 86 | echo "❌ Unknown bump type: $bump_type" >&2 87 | exit 1 88 | ;; 89 | esac 90 | } 91 | 92 | # Function to validate version format 93 | validate_version() { 94 | local version=$1 95 | if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?$ ]]; then 96 | echo "❌ Invalid version format: $version" 97 | echo "Version must be in format: x.y.z or x.y.z-tag.n (e.g., 1.4.0 or 1.4.0-beta.3)" 98 | return 1 99 | fi 100 | return 0 101 | } 102 | 103 | # Function to compare versions including prerelease (1 if v1>v2, 0 if equal, -1 if less) 104 | compare_versions() { 105 | local v1=$1 106 | local v2=$2 107 | if [[ "$v1" == "$v2" ]]; then 108 | echo 0 109 | return 110 | fi 111 | local highest 112 | highest=$(printf "%s\n%s" "$v1" "$v2" | sort -V | tail -1) 113 | if [[ "$highest" == "$v1" ]]; then 114 | echo 1 115 | else 116 | echo -1 117 | fi 118 | } 119 | 120 | # Function to ask for confirmation 121 | ask_confirmation() { 122 | local suggested_version=$1 123 | echo "" 124 | echo "🚀 Suggested next version: $suggested_version" 125 | read -p "Do you want to use this version? (y/N): " -n 1 -r 126 | echo 127 | if [[ $REPLY =~ ^[Yy]$ ]]; then 128 | return 0 129 | else 130 | return 1 131 | fi 132 | } 133 | 134 | # Function to get version interactively 135 | get_version_interactively() { 136 | echo "" 137 | echo "Please enter the version manually:" 138 | while true; do 139 | read -p "Version: " manual_version 140 | if validate_version "$manual_version"; then 141 | local highest_version=$(get_highest_version) 142 | if [[ -n "$highest_version" ]]; then 143 | local comparison=$(compare_versions "$manual_version" "$highest_version") 144 | if [[ $comparison -le 0 ]]; then 145 | echo "❌ Version $manual_version is not newer than the highest existing version $highest_version" 146 | continue 147 | fi 148 | fi 149 | VERSION="$manual_version" 150 | break 151 | fi 152 | done 153 | } 154 | 155 | # Check for help flags first 156 | for arg in "$@"; do 157 | if [[ "$arg" == "-h" ]] || [[ "$arg" == "--help" ]]; then 158 | show_help 159 | exit 0 160 | fi 161 | done 162 | 163 | # Check for arguments and set flags 164 | for arg in "$@"; do 165 | if [[ "$arg" == "--dry-run" ]]; then 166 | DRY_RUN=true 167 | fi 168 | done 169 | 170 | # Determine version or bump type (ignore --dry-run flag) 171 | if [[ -z "$FIRST_ARG" ]] || [[ "$FIRST_ARG" == "--dry-run" ]]; then 172 | # No argument provided, default to minor bump 173 | BUMP_TYPE="minor" 174 | elif [[ "$FIRST_ARG" == "major" ]] || [[ "$FIRST_ARG" == "minor" ]] || [[ "$FIRST_ARG" == "patch" ]]; then 175 | # Bump type provided 176 | BUMP_TYPE="$FIRST_ARG" 177 | else 178 | # Version string provided 179 | if validate_version "$FIRST_ARG"; then 180 | VERSION="$FIRST_ARG" 181 | else 182 | exit 1 183 | fi 184 | fi 185 | 186 | # If bump type is set, calculate the suggested version 187 | if [[ -n "$BUMP_TYPE" ]]; then 188 | HIGHEST_VERSION=$(get_highest_version) 189 | if [[ -z "$HIGHEST_VERSION" ]]; then 190 | echo "❌ No existing version tags found. Please provide a version manually." 191 | get_version_interactively 192 | else 193 | SUGGESTED_VERSION=$(bump_version "$HIGHEST_VERSION" "$BUMP_TYPE") 194 | 195 | if ask_confirmation "$SUGGESTED_VERSION"; then 196 | VERSION="$SUGGESTED_VERSION" 197 | else 198 | get_version_interactively 199 | fi 200 | fi 201 | fi 202 | 203 | # Final validation and version comparison 204 | if [[ -z "$VERSION" ]]; then 205 | echo "❌ No version determined" 206 | exit 1 207 | fi 208 | 209 | HIGHEST_VERSION=$(get_highest_version) 210 | if [[ -n "$HIGHEST_VERSION" ]]; then 211 | COMPARISON=$(compare_versions "$VERSION" "$HIGHEST_VERSION") 212 | if [[ $COMPARISON -le 0 ]]; then 213 | echo "❌ Version $VERSION is not newer than the highest existing version $HIGHEST_VERSION" 214 | exit 1 215 | fi 216 | fi 217 | 218 | # Detect current branch 219 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 220 | 221 | # Enforce branch policy - only allow releases from main 222 | if [[ "$BRANCH" != "main" ]]; then 223 | echo "❌ Error: Releases must be created from the main branch." 224 | echo "Current branch: $BRANCH" 225 | echo "Please switch to main and try again." 226 | exit 1 227 | fi 228 | 229 | run() { 230 | if $DRY_RUN; then 231 | echo "[dry-run] $*" 232 | else 233 | eval "$@" 234 | fi 235 | } 236 | 237 | # Ensure we're in the project root (parent of scripts directory) 238 | cd "$(dirname "$0")/.." 239 | 240 | # Check if working directory is clean 241 | if ! git diff-index --quiet HEAD --; then 242 | echo "❌ Error: Working directory is not clean." 243 | echo "Please commit or stash your changes before creating a release." 244 | exit 1 245 | fi 246 | 247 | # Check if package.json already has this version (from previous attempt) 248 | CURRENT_PACKAGE_VERSION=$(node -p "require('./package.json').version") 249 | if [[ "$CURRENT_PACKAGE_VERSION" == "$VERSION" ]]; then 250 | echo "📦 Version $VERSION already set in package.json" 251 | SKIP_VERSION_UPDATE=true 252 | else 253 | SKIP_VERSION_UPDATE=false 254 | fi 255 | 256 | if [[ "$SKIP_VERSION_UPDATE" == "false" ]]; then 257 | # Version update 258 | echo "" 259 | echo "🔧 Setting version to $VERSION..." 260 | run "npm version \"$VERSION\" --no-git-tag-version" 261 | 262 | # README update 263 | echo "" 264 | echo "📝 Updating version in README.md..." 265 | # Update version references in code examples using extended regex for precise semver matching 266 | # Portable in-place sed for GNU/BSD 267 | if sed --version >/dev/null 2>&1; then 268 | run "sed -i -E 's/@[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+\.[0-9]+)?(-[A-Za-z0-9]+\.[0-9]+)*(-[A-Za-z0-9]+)?/@'"$VERSION"'/g' README.md" 269 | else 270 | run "sed -i '' -E 's/@[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+\.[0-9]+)?(-[A-Za-z0-9]+\.[0-9]+)*(-[A-Za-z0-9]+)?/@'"$VERSION"'/g' README.md" 271 | fi 272 | 273 | # Git operations 274 | echo "" 275 | echo "📦 Committing version changes..." 276 | run "git add package.json README.md" 277 | run "git commit -m \"Release v$VERSION\"" 278 | else 279 | echo "⏭️ Skipping version update (already done)" 280 | fi 281 | 282 | # Create or recreate tag at current HEAD 283 | echo "🏷️ Creating tag v$VERSION..." 284 | run "git tag -f \"v$VERSION\"" 285 | 286 | echo "" 287 | echo "🚀 Pushing to origin..." 288 | run "git push origin $BRANCH --tags" 289 | 290 | # Monitor the workflow and handle failures 291 | echo "" 292 | echo "⏳ Monitoring GitHub Actions workflow..." 293 | echo "This may take a few minutes..." 294 | 295 | # Wait for workflow to start 296 | sleep 5 297 | 298 | # Get the workflow run ID for this tag 299 | RUN_ID=$(gh run list --workflow=release.yml --limit=1 --json databaseId --jq '.[0].databaseId') 300 | 301 | if [[ -n "$RUN_ID" ]]; then 302 | echo "📊 Workflow run ID: $RUN_ID" 303 | echo "🔍 Watching workflow progress..." 304 | echo "(Press Ctrl+C to detach and monitor manually)" 305 | echo "" 306 | 307 | # Watch the workflow with exit status 308 | if gh run watch "$RUN_ID" --exit-status; then 309 | echo "" 310 | echo "✅ Release v$VERSION completed successfully!" 311 | echo "📦 View on NPM: https://www.npmjs.com/package/mcpli/v/$VERSION" 312 | echo "🎉 View release: https://github.com/cameroncooke/mcpli/releases/tag/v$VERSION" 313 | else 314 | echo "" 315 | echo "❌ CI workflow failed!" 316 | echo "" 317 | echo "🧹 Cleaning up tags only (keeping version commit)..." 318 | 319 | # Delete remote tag 320 | echo " - Deleting remote tag v$VERSION..." 321 | git push origin :refs/tags/v$VERSION 2>/dev/null || true 322 | 323 | # Delete local tag 324 | echo " - Deleting local tag v$VERSION..." 325 | git tag -d v$VERSION 326 | 327 | echo "" 328 | echo "✅ Tag cleanup complete!" 329 | echo "" 330 | echo "ℹ️ The version commit remains in your history." 331 | echo "📝 To retry after fixing issues:" 332 | echo " 1. Fix the CI issues" 333 | echo " 2. Commit your fixes" 334 | echo " 3. Run: ./scripts/release.sh $VERSION" 335 | echo "" 336 | echo "🔍 To see what failed: gh run view $RUN_ID --log-failed" 337 | exit 1 338 | fi 339 | else 340 | echo "⚠️ Could not find workflow run. Please check manually:" 341 | echo "https://github.com/cameroncooke/mcpli/actions" 342 | fi -------------------------------------------------------------------------------- /src/daemon/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sendIPCRequest, 3 | generateRequestId, 4 | ToolListResult, 5 | ToolCallResult, 6 | ToolCallParams, 7 | } from './ipc.ts'; 8 | import { 9 | resolveOrchestrator, 10 | computeDaemonId, 11 | deriveIdentityEnv, 12 | Orchestrator, 13 | } from './runtime.ts'; 14 | import { getConfig } from '../config.ts'; 15 | import { parsePositiveIntMs, getDefaultToolTimeoutMs } from './mcp-client-utils.ts'; 16 | 17 | /** 18 | * Options for interacting with the MCPLI daemon through the orchestrator. 19 | * - `cwd`: Working directory used to scope daemon identities and artifacts. 20 | * - `env`: Environment variables to pass to the MCP server (identity-affecting). 21 | * - `debug`: Emit detailed timing and diagnostics to stderr. 22 | * - `logs`/`verbose`: Request immediate start and stream OSLog in certain flows. 23 | * - `timeout`: Inactivity timeout (seconds) for the daemon wrapper. 24 | */ 25 | export interface DaemonClientOptions { 26 | /** Working directory used to scope daemon identity and artifacts. */ 27 | cwd?: string; 28 | /** Environment variables passed to the MCP server (affects identity). */ 29 | env?: Record; 30 | /** Enable detailed timing and diagnostics. */ 31 | debug?: boolean; 32 | /** Suggest immediate start and OSLog streaming in certain flows. */ 33 | logs?: boolean; 34 | /** Increase verbosity (may imply logs). */ 35 | verbose?: boolean; 36 | /** Inactivity timeout (seconds) for the daemon. */ 37 | timeout?: number; 38 | /** IPC request timeout in milliseconds (overrides config default). */ 39 | ipcTimeoutMs?: number; 40 | /** Default tool timeout in milliseconds (front-facing). */ 41 | toolTimeoutMs?: number; 42 | } 43 | 44 | /** 45 | * Lightweight client that ensures the appropriate daemon exists and 46 | * proxies a single request over IPC. Automatically computes a stable 47 | * daemon id from command/args/env and uses the platform orchestrator 48 | * (launchd on macOS) for lifecycle management. 49 | */ 50 | export class DaemonClient { 51 | private daemonId?: string; 52 | private orchestratorPromise: Promise; 53 | private ipcTimeoutMs: number; 54 | 55 | /** 56 | * Resolve the effective front-facing tool timeout (milliseconds). 57 | * Priority: explicit client option > global/config default. 58 | */ 59 | private resolveEffectiveToolTimeoutMs(): number { 60 | const fromFlag = parsePositiveIntMs(this.options.toolTimeoutMs); 61 | if (typeof fromFlag === 'number') { 62 | return Math.max(1000, fromFlag); 63 | } 64 | const env = this.options.env ?? {}; 65 | const fromFrontEnv = parsePositiveIntMs( 66 | (env as unknown as Record)['MCPLI_TOOL_TIMEOUT_MS'], 67 | ); 68 | if (typeof fromFrontEnv === 'number') { 69 | return Math.max(1000, fromFrontEnv); 70 | } 71 | return Math.max(1000, getDefaultToolTimeoutMs()); 72 | } 73 | 74 | /** 75 | * Whether the tool timeout was explicitly provided (vs coming from defaults). 76 | */ 77 | private isToolTimeoutExplicit(): boolean { 78 | const fromFlag = parsePositiveIntMs(this.options.toolTimeoutMs); 79 | if (typeof fromFlag === 'number') return true; 80 | const env = this.options.env ?? {}; 81 | const fromFrontEnv = parsePositiveIntMs( 82 | (env as unknown as Record)['MCPLI_TOOL_TIMEOUT_MS'], 83 | ); 84 | return typeof fromFrontEnv === 'number'; 85 | } 86 | 87 | /** 88 | * Construct a client for a given MCP server command. 89 | * 90 | * @param command MCP server executable. 91 | * @param args Arguments to the MCP server executable. 92 | * @param options Client options controlling env, cwd, and verbosity. 93 | */ 94 | constructor( 95 | private command: string, 96 | private args: string[], 97 | private options: DaemonClientOptions = {}, 98 | ) { 99 | this.options = { 100 | ...options, 101 | }; 102 | 103 | // Configure IPC timeout from options or global config 104 | const cfg = getConfig(); 105 | this.ipcTimeoutMs = Math.max( 106 | 1000, 107 | Math.trunc(this.options.ipcTimeoutMs ?? cfg.defaultIpcTimeoutMs), 108 | ); 109 | 110 | // Resolve orchestrator (launchd-only architecture) 111 | this.orchestratorPromise = resolveOrchestrator(); 112 | 113 | // Compute daemonId only when we have a command 114 | if (this.command?.trim()) { 115 | const identityEnv = deriveIdentityEnv(this.options.env ?? {}); 116 | this.daemonId = computeDaemonId(this.command, this.args, identityEnv); 117 | } 118 | } 119 | 120 | private async prepareRequest( 121 | method: 'listTools' | 'callTool' | 'ping', 122 | params?: ToolCallParams, 123 | ): Promise<{ 124 | socketPath: string; 125 | request: { id: string; method: 'listTools' | 'callTool' | 'ping'; params?: ToolCallParams }; 126 | timeoutForRequest: number; 127 | connectRetryBudgetMs?: number; 128 | }> { 129 | const cwd = this.options.cwd ?? process.cwd(); 130 | const orchestrator = await this.orchestratorPromise; 131 | 132 | if (!this.command && !this.daemonId) { 133 | throw new Error('No daemon identity available and no server command provided'); 134 | } 135 | 136 | if (this.options.debug) console.time('[DEBUG] orchestrator.ensure'); 137 | const effectiveToolTimeoutMs = this.resolveEffectiveToolTimeoutMs(); 138 | const toolTimeoutExplicit = this.isToolTimeoutExplicit(); 139 | 140 | const ensureRes = await orchestrator.ensure(this.command, this.args, { 141 | cwd, 142 | env: this.options.env ?? {}, 143 | debug: this.options.debug, 144 | logs: Boolean(this.options.logs ?? this.options.verbose), 145 | verbose: this.options.verbose, 146 | timeout: this.options.timeout, 147 | toolTimeoutMs: effectiveToolTimeoutMs, 148 | preferImmediateStart: Boolean( 149 | this.options.logs ?? this.options.verbose ?? this.options.debug, 150 | ), 151 | }); 152 | if (this.options.debug) { 153 | console.timeEnd('[DEBUG] orchestrator.ensure'); 154 | console.debug( 155 | `[DEBUG] ensure result: action=${ensureRes.updateAction ?? 'unchanged'}, started=${ensureRes.started ? '1' : '0'}, pid=${typeof ensureRes.pid === 'number' ? ensureRes.pid : 'n/a'}`, 156 | ); 157 | } 158 | 159 | const reqId = generateRequestId(); 160 | const request = { id: reqId, method, params } as const; 161 | 162 | const timeoutForRequest: number = ((): number => { 163 | if (method === 'callTool') { 164 | return Math.max(this.ipcTimeoutMs, effectiveToolTimeoutMs + 60_000); 165 | } 166 | if (method === 'listTools' && toolTimeoutExplicit) { 167 | return Math.max(this.ipcTimeoutMs, effectiveToolTimeoutMs + 60_000); 168 | } 169 | return this.ipcTimeoutMs; 170 | })(); 171 | 172 | const needsExtraConnectBudget = 173 | ensureRes.updateAction === 'loaded' || 174 | ensureRes.updateAction === 'reloaded' || 175 | !!ensureRes.started; 176 | const connectRetryBudgetMs = needsExtraConnectBudget ? 8000 : undefined; 177 | 178 | return { 179 | socketPath: ensureRes.socketPath, 180 | request, 181 | timeoutForRequest, 182 | connectRetryBudgetMs, 183 | }; 184 | } 185 | 186 | private async sendWithOptionalCancel( 187 | method: 'listTools' | 'callTool' | 'ping', 188 | params?: ToolCallParams, 189 | signal?: AbortSignal, 190 | ): Promise { 191 | if (signal?.aborted) { 192 | throw new Error('Operation aborted'); 193 | } 194 | 195 | const { socketPath, request, timeoutForRequest, connectRetryBudgetMs } = 196 | await this.prepareRequest(method, params); 197 | 198 | if (signal?.aborted) { 199 | throw new Error('Operation aborted'); 200 | } 201 | 202 | let removeAbort: (() => void) | undefined; 203 | if (signal) { 204 | const onAbort = (): void => { 205 | void sendIPCRequest( 206 | socketPath, 207 | { 208 | id: generateRequestId(), 209 | method: 'cancelCall', 210 | params: { ipcRequestId: request.id, reason: String(signal.reason ?? 'aborted') }, 211 | }, 212 | 2000, 213 | ).catch(() => {}); 214 | }; 215 | const onAbortListener: (ev: Event) => void = (ev: Event): void => { 216 | void ev; 217 | onAbort(); 218 | }; 219 | signal.addEventListener('abort', onAbortListener, { once: true }); 220 | removeAbort = (): void => signal.removeEventListener?.('abort', onAbortListener); 221 | } 222 | 223 | try { 224 | return await sendIPCRequest( 225 | socketPath, 226 | request, 227 | timeoutForRequest, 228 | connectRetryBudgetMs, 229 | signal, 230 | ); 231 | } catch (err) { 232 | if (signal?.aborted) { 233 | throw new Error('Operation aborted'); 234 | } 235 | throw err; 236 | } finally { 237 | removeAbort?.(); 238 | } 239 | } 240 | 241 | /** 242 | * Query the MCP server (via daemon) for available tools. 243 | * 244 | * @returns Tool list result from the daemon. 245 | */ 246 | async listTools(): Promise { 247 | const result = await this.sendWithOptionalCancel('listTools'); 248 | return result as ToolListResult; 249 | } 250 | 251 | /** 252 | * Execute a specific tool over IPC, returning the raw MCP tool result. 253 | * 254 | * @param params Tool call parameters including name and arguments. 255 | * @returns Raw MCP tool call result. 256 | */ 257 | async callTool( 258 | params: ToolCallParams, 259 | options?: { signal?: AbortSignal }, 260 | ): Promise { 261 | const result = await this.sendWithOptionalCancel('callTool', params, options?.signal); 262 | return result as ToolCallResult; 263 | } 264 | 265 | /** 266 | * Initiate a cancellable tool call. Returns the IPC request id and socket path 267 | * so callers can send a `cancelCall` IPC request on Ctrl+C, plus a cancel helper. 268 | */ 269 | 270 | private async callDaemon( 271 | method: 'listTools' | 'callTool' | 'ping', 272 | params?: ToolCallParams, 273 | ): Promise { 274 | return await this.sendWithOptionalCancel(method, params); 275 | } 276 | 277 | /** 278 | * Lightweight liveness check. 279 | * 280 | * @returns True if the daemon responds to a ping. 281 | */ 282 | async ping(): Promise { 283 | try { 284 | const result = await this.callDaemon('ping'); 285 | return result === 'pong'; 286 | } catch { 287 | return false; 288 | } 289 | } 290 | } 291 | 292 | /** 293 | * Helper to create a `DaemonClient`, run an async operation, and return the 294 | * result. 295 | * 296 | * @param command MCP server executable. 297 | * @param args Arguments for the MCP server. 298 | * @param options Client options (cwd, env, debug, etc.). 299 | * @param operation Async function that receives the client and returns a value. 300 | * @returns Result of the operation. 301 | */ 302 | export async function withDaemonClient( 303 | command: string, 304 | args: string[], 305 | options: DaemonClientOptions, 306 | operation: (client: DaemonClient) => Promise, 307 | ): Promise { 308 | const client = new DaemonClient(command, args, options); 309 | return await operation(client); 310 | } 311 | -------------------------------------------------------------------------------- /src/daemon/runtime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Orchestrator abstraction for managing MCPLI daemons. 3 | * 4 | * This interface intentionally removes any dependency on legacy file-lock logic. 5 | * The initial concrete implementation targets macOS launchd with socket activation. 6 | */ 7 | 8 | import path from 'path'; 9 | import { createHash } from 'crypto'; 10 | 11 | /** 12 | * The only orchestrator in this architecture is launchd (macOS). 13 | * Additional implementations can be added in the future if needed. 14 | */ 15 | /** 16 | * Known orchestrator types. Currently only macOS launchd is supported. 17 | */ 18 | export type OrchestratorName = 'launchd'; 19 | 20 | /** 21 | * Options that control how a daemon is ensured/started by the orchestrator. 22 | * - `cwd`: Scope job artifacts and identity to this directory. 23 | * - `env`: Environment passed through to the MCP server (affects identity). 24 | * - `timeout`: Daemon inactivity timeout (seconds). Mutually exclusive with `timeoutMs`. 25 | * - `preferImmediateStart`: Start immediately vs rely on socket activation. 26 | */ 27 | export interface EnsureOptions { 28 | /** Working directory that scopes daemon identity and artifacts. */ 29 | cwd?: string; 30 | /** Environment passed to the MCP server process (affects identity). */ 31 | env?: Record; 32 | /** Enable debug diagnostics and timing. */ 33 | debug?: boolean; 34 | /** Request that logs be made available (implementation dependent). */ 35 | logs?: boolean; 36 | /** Increase verbosity (may imply logs). */ 37 | verbose?: boolean; 38 | /** Suppress routine output where applicable. */ 39 | quiet?: boolean; 40 | /** Daemon inactivity timeout in seconds. */ 41 | timeout?: number; 42 | /** Daemon inactivity timeout in milliseconds (overrides `timeout` if set). */ 43 | timeoutMs?: number; 44 | /** Default MCP tool timeout (ms) to pass to wrapper; does not affect identity. */ 45 | toolTimeoutMs?: number; 46 | /** 47 | * Hint to start the service immediately rather than lazily on first connection (if supported). 48 | */ 49 | preferImmediateStart?: boolean; 50 | } 51 | 52 | /** 53 | * Result of an ensure call, including the daemon id, socket path, current 54 | * process state, and what action (if any) was taken to update the job. 55 | */ 56 | export interface EnsureResult { 57 | /** Stable identity derived from normalized (command, args, env). */ 58 | id: string; 59 | /** Absolute path to the Unix domain socket this daemon listens on. */ 60 | socketPath: string; 61 | /** launchd job label (launchd-specific). */ 62 | label?: string; 63 | /** PID of a currently running process, if determinable. */ 64 | pid?: number; 65 | /** 66 | * Orchestrator action taken when ensuring the job: 67 | * - 'loaded' when a previously-unloaded job was loaded, 68 | * - 'reloaded' when an existing job was updated (bootout + bootstrap), 69 | * - 'unchanged' when no plist content change required and loaded state preserved. 70 | */ 71 | updateAction?: 'loaded' | 'reloaded' | 'unchanged'; 72 | /** True if ensure actively attempted to start the job via kickstart. */ 73 | started?: boolean; 74 | } 75 | 76 | /** 77 | * Status entry for a daemon managed under the current working directory. 78 | */ 79 | export interface RuntimeStatus { 80 | /** Stable daemon id. */ 81 | id: string; 82 | /** launchd label if available. */ 83 | label?: string; 84 | /** Whether the orchestrator has a loaded job in the domain. */ 85 | loaded: boolean; 86 | /** Whether a process is currently running/attached to the job (best-effort). */ 87 | running: boolean; 88 | /** Current PID if discoverable. */ 89 | pid?: number; 90 | /** Socket path if known. */ 91 | socketPath?: string; 92 | /** Optional additional metadata fields (e.g., timestamps). */ 93 | [key: string]: unknown; 94 | } 95 | 96 | /** 97 | * Core orchestration interface. 98 | * Implementations should be idempotent and safe to call concurrently. 99 | */ 100 | /** 101 | * Abstraction for platform-specific daemon orchestration. 102 | */ 103 | export interface Orchestrator { 104 | readonly type: OrchestratorName; 105 | 106 | /** 107 | * Compute a stable daemon id using the same normalization rules as the orchestrator. 108 | * Implementations should generally delegate to computeDaemonId(). 109 | */ 110 | computeId(command: string, args: string[], env?: Record, cwd?: string): string; 111 | 112 | /** 113 | * Ensure that a daemon exists for the provided command/args/env. 114 | * Implementations may create or bootstrap the job on-demand. 115 | */ 116 | ensure(command: string, args: string[], opts: EnsureOptions): Promise; 117 | 118 | /** 119 | * Stop a specific daemon by id, or all daemons under a given cwd if id is omitted. 120 | */ 121 | stop(id?: string): Promise; 122 | 123 | /** 124 | * List orchestrator-managed daemons scoped to a working directory. 125 | */ 126 | status(): Promise; 127 | 128 | /** 129 | * Clean up any orchestrator artifacts (plists, sockets, metadata) scoped to the working directory. 130 | */ 131 | clean(): Promise; 132 | } 133 | 134 | /** 135 | * Normalize command and args across platforms (absolute path, normalized separators). 136 | * This mirrors the semantics used by identity hashing to ensure cross-process stability. 137 | */ 138 | /** 139 | * Normalize a command and arguments into absolute, platform-stable strings. 140 | * 141 | * @param command The executable to run. 142 | * @param args Arguments to pass to the executable. 143 | * @returns Normalized command and args with absolute command path. 144 | */ 145 | export function normalizeCommand( 146 | command: string, 147 | args: string[] = [], 148 | ): { command: string; args: string[] } { 149 | const trimmed = String(command || '').trim(); 150 | 151 | const looksPathLike = (s: string): boolean => { 152 | if (!s) return false; 153 | if (path.isAbsolute(s)) return true; 154 | if (s.startsWith('./') || s.startsWith('../')) return true; 155 | if (s.includes('/')) return true; 156 | return false; 157 | }; 158 | 159 | const normCommand = looksPathLike(trimmed) ? path.normalize(path.resolve(trimmed)) : trimmed; 160 | 161 | const normArgs = (Array.isArray(args) ? args : []) 162 | .map((a) => String(a ?? '').trim()) 163 | .filter((a) => a.length > 0); 164 | 165 | return { command: normCommand, args: normArgs }; 166 | } 167 | 168 | /** 169 | * Normalize environment for identity hashing. 170 | * - On Windows, keys are treated case-insensitively (uppercased). 171 | * - Keys are sorted to ensure deterministic hashing. 172 | * - Values are coerced to strings. 173 | */ 174 | /** 175 | * Normalize an environment object for identity hashing: coerces values to 176 | * strings and sorts keys (case-insensitive on Windows). 177 | * 178 | * @param env Environment key/value pairs to normalize. 179 | * @returns A new object with normalized and sorted keys. 180 | */ 181 | export function normalizeEnv(env: Record = {}): Record { 182 | const out: Record = {}; 183 | for (const [k, v] of Object.entries(env)) { 184 | const key = process.platform === 'win32' ? k.toUpperCase() : k; 185 | out[key] = String(v ?? ''); 186 | } 187 | return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); 188 | } 189 | 190 | /** 191 | * Derive the effective environment to be used for identity. 192 | */ 193 | /** 194 | * Derive the effective environment to use for identity hashing from the 195 | * explicit CommandSpec-only values provided after `--`. 196 | * 197 | * @param explicitEnv The explicit env from CommandSpec (after --). 198 | * @returns Normalized identity env. 199 | */ 200 | export function deriveIdentityEnv( 201 | explicitEnv: Record = {}, 202 | ): Record { 203 | const base: Record = {}; 204 | // New behavior: 205 | // Identity hashing must only consider environment variables explicitly provided 206 | // in the server CommandSpec (after the --). The ambient shell environment 207 | // (process.env) must be ignored to ensure stable identity across different 208 | // shells and sessions. 209 | // 210 | // Note: We purposefully do not merge process.env here. We also do not filter 211 | // out MCPLI_* because the CommandSpec is fully controlled by the user; if they 212 | // include MCPLI_* intentionally after --, it will be part of identity. 213 | // Normalize keys/values and sort for deterministic hashing. 214 | void base; // keep anchor's first body line variable referenced 215 | // Only include explicit environment from CommandSpec 216 | return normalizeEnv(explicitEnv); 217 | } 218 | 219 | /** 220 | * Compute a deterministic 8-char id from normalized command, args, and env. 221 | * This is the canonical identity for MCPLI daemons and should remain stable across orchestrators. 222 | */ 223 | /** 224 | * Compute a deterministic 8-character id from normalized command, args, and env. 225 | * 226 | * @param command Executable path or name. 227 | * @param args Arguments for the executable. 228 | * @param env Identity-affecting environment. 229 | * @returns An 8-character hexadecimal id. 230 | */ 231 | export function computeDaemonId( 232 | command: string, 233 | args: string[] = [], 234 | env: Record = {}, 235 | ): string { 236 | const norm = normalizeCommand(command, args); 237 | const normEnv = normalizeEnv(env); 238 | const input = JSON.stringify([norm.command, ...norm.args, { env: normEnv }]); 239 | const digest = createHash('sha256').update(input).digest('hex'); 240 | return digest.slice(0, 8); 241 | } 242 | 243 | const DAEMON_ID_REGEX = /^[a-z0-9_-]{1,64}$/i; 244 | 245 | /** 246 | * Validate daemon id format (1..64 chars of /[a-z0-9_-]/i). 247 | * 248 | * @param id Candidate id string. 249 | * @returns True when the id is valid. 250 | */ 251 | export function isValidDaemonId(id: string): boolean { 252 | return typeof id === 'string' && DAEMON_ID_REGEX.test(id); 253 | } 254 | 255 | /** 256 | * Resolve the orchestrator implementation. 257 | * - On macOS (darwin), selects the launchd-based orchestrator. 258 | * - On other platforms, this phase intentionally throws (launchd-only architecture). 259 | * 260 | * Note: Uses a runtime dynamic import to avoid compile-time coupling before the 261 | * concrete implementation (runtime-launchd.ts) is added. 262 | */ 263 | /** 264 | * Resolve the orchestrator implementation for the current platform. 265 | * macOS only in this phase (launchd). 266 | * 267 | * @returns A resolved orchestrator instance. 268 | */ 269 | export async function resolveOrchestrator(): Promise { 270 | if (process.platform !== 'darwin') { 271 | throw new Error( 272 | 'MCPLI launchd orchestrator is only supported on macOS (darwin) in this architecture phase.', 273 | ); 274 | } 275 | 276 | // Allow env override for future extensibility; for now, we only support launchd. 277 | const forced = process.env.MCPLI_RUNTIME?.toLowerCase(); 278 | if (forced && forced !== 'launchd') { 279 | throw new Error( 280 | `Unsupported MCPLI_RUNTIME="${forced}". Only "launchd" is supported currently.`, 281 | ); 282 | } 283 | 284 | // Dynamic import to LaunchdRuntime 285 | let mod: unknown; 286 | try { 287 | mod = await import('./runtime-launchd.js'); 288 | } catch (err) { 289 | throw new Error( 290 | `Launchd orchestrator module not found. Expected runtime-launchd module. Error: ${err}`, 291 | ); 292 | } 293 | 294 | const LaunchdCtor = 295 | (mod as { LaunchdRuntime?: new () => Orchestrator }).LaunchdRuntime ?? 296 | (mod as { default?: new () => Orchestrator }).default; 297 | 298 | if (typeof LaunchdCtor !== 'function') { 299 | throw new Error( 300 | 'Invalid launchd orchestrator module shape. Export class LaunchdRuntime implementing Orchestrator.', 301 | ); 302 | } 303 | 304 | const instance: Orchestrator = new LaunchdCtor(); 305 | return instance; 306 | } 307 | 308 | /** 309 | * A lightweight base class that orchestrator implementations may extend to inherit 310 | * common identity behavior. Optional to use. 311 | */ 312 | /** 313 | * Convenience base class that implements common identity semantics for 314 | * orchestrator implementations. 315 | */ 316 | export abstract class BaseOrchestrator implements Orchestrator { 317 | abstract readonly type: OrchestratorName; 318 | 319 | computeId(command: string, args: string[], env: Record = {}): string { 320 | return computeDaemonId(command, args, deriveIdentityEnv(env)); 321 | } 322 | 323 | abstract ensure(command: string, args: string[], opts: EnsureOptions): Promise; 324 | abstract stop(id?: string): Promise; 325 | abstract status(): Promise; 326 | abstract clean(): Promise; 327 | } 328 | -------------------------------------------------------------------------------- /weather-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Weather MCP Server 5 | * 6 | * A simple MCP server that provides weather information using the free 7 | * Open-Meteo API (no API key required). 8 | * 9 | * Supports: 10 | * - City name lookup (geocoded to coordinates) 11 | * - Direct latitude/longitude coordinates 12 | * - Current weather and forecasts 13 | */ 14 | 15 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 16 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 17 | import { 18 | ListToolsRequestSchema, 19 | CallToolRequestSchema 20 | } from '@modelcontextprotocol/sdk/types.js'; 21 | import { z } from 'zod'; 22 | 23 | // Free geocoding service to convert city names to coordinates 24 | async function geocodeCity(cityName) { 25 | try { 26 | const response = await fetch( 27 | `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json` 28 | ); 29 | const data = await response.json(); 30 | 31 | if (!data.results || data.results.length === 0) { 32 | throw new Error(`City "${cityName}" not found`); 33 | } 34 | 35 | const result = data.results[0]; 36 | return { 37 | latitude: result.latitude, 38 | longitude: result.longitude, 39 | name: result.name, 40 | country: result.country, 41 | admin1: result.admin1 // State/region 42 | }; 43 | } catch (error) { 44 | throw new Error(`Geocoding failed: ${error.message}`); 45 | } 46 | } 47 | 48 | // Get weather data from Open-Meteo API 49 | async function getWeatherData(latitude, longitude, options = {}) { 50 | try { 51 | const params = new URLSearchParams({ 52 | latitude: latitude.toString(), 53 | longitude: longitude.toString(), 54 | current: 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m', 55 | timezone: 'auto', 56 | temperature_unit: options.units === 'fahrenheit' ? 'fahrenheit' : 'celsius', 57 | wind_speed_unit: 'mph', 58 | precipitation_unit: 'inch' 59 | }); 60 | 61 | if (options.forecast_days) { 62 | params.append('daily', 'temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum'); 63 | params.append('forecast_days', options.forecast_days.toString()); 64 | } 65 | 66 | const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); 67 | const data = await response.json(); 68 | 69 | if (data.error) { 70 | throw new Error(`Weather API error: ${data.reason}`); 71 | } 72 | 73 | return data; 74 | } catch (error) { 75 | throw new Error(`Weather API failed: ${error.message}`); 76 | } 77 | } 78 | 79 | // Convert weather codes to human-readable descriptions 80 | function getWeatherDescription(code) { 81 | const descriptions = { 82 | 0: 'Clear sky', 83 | 1: 'Mainly clear', 84 | 2: 'Partly cloudy', 85 | 3: 'Overcast', 86 | 45: 'Fog', 87 | 48: 'Depositing rime fog', 88 | 51: 'Light drizzle', 89 | 53: 'Moderate drizzle', 90 | 55: 'Dense drizzle', 91 | 61: 'Slight rain', 92 | 63: 'Moderate rain', 93 | 65: 'Heavy rain', 94 | 71: 'Slight snow', 95 | 73: 'Moderate snow', 96 | 75: 'Heavy snow', 97 | 80: 'Slight rain showers', 98 | 81: 'Moderate rain showers', 99 | 82: 'Violent rain showers', 100 | 95: 'Thunderstorm', 101 | 96: 'Thunderstorm with hail', 102 | 99: 'Thunderstorm with heavy hail' 103 | }; 104 | 105 | return descriptions[code] || `Unknown weather (code: ${code})`; 106 | } 107 | 108 | // Create and configure the MCP server 109 | const server = new Server( 110 | { 111 | name: 'weather-server', 112 | version: '1.0.0' 113 | }, 114 | { 115 | capabilities: { 116 | tools: {} 117 | } 118 | } 119 | ); 120 | 121 | // Register the get-weather tool 122 | server.setRequestHandler(ListToolsRequestSchema, async () => { 123 | return { 124 | tools: [ 125 | { 126 | name: 'get_weather', 127 | description: 'Get current weather information for any location', 128 | inputSchema: { 129 | type: 'object', 130 | properties: { 131 | location: { 132 | type: 'string', 133 | description: 'City name (e.g., "New York", "London, UK") or coordinates as "lat,lon"' 134 | }, 135 | units: { 136 | type: 'string', 137 | description: 'Temperature units', 138 | enum: ['celsius', 'fahrenheit'], 139 | default: 'fahrenheit' 140 | } 141 | }, 142 | required: ['location'] 143 | } 144 | }, 145 | { 146 | name: 'get_forecast', 147 | description: 'Get weather forecast for multiple days', 148 | inputSchema: { 149 | type: 'object', 150 | properties: { 151 | location: { 152 | type: 'string', 153 | description: 'City name (e.g., "New York", "London, UK") or coordinates as "lat,lon"' 154 | }, 155 | days: { 156 | type: 'integer', 157 | description: 'Number of forecast days (1-16)', 158 | minimum: 1, 159 | maximum: 16, 160 | default: 5 161 | }, 162 | units: { 163 | type: 'string', 164 | description: 'Temperature units', 165 | enum: ['celsius', 'fahrenheit'], 166 | default: 'fahrenheit' 167 | } 168 | }, 169 | required: ['location'] 170 | } 171 | } 172 | ] 173 | }; 174 | }); 175 | 176 | // Handle tool calls 177 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 178 | const { name, arguments: args } = request.params ?? { name: undefined, arguments: undefined }; 179 | 180 | // Zod schemas for robust parameter validation 181 | const GetWeatherSchema = z.object({ 182 | location: z.string().min(1, 'location must be a non-empty string'), 183 | units: z.enum(['celsius', 'fahrenheit']).optional(), 184 | }); 185 | const GetForecastSchema = z.object({ 186 | location: z.string().min(1, 'location must be a non-empty string'), 187 | days: z 188 | .number({ invalid_type_error: 'days must be a number' }) 189 | .int('days must be an integer') 190 | .min(1, 'days must be between 1 and 16') 191 | .max(16, 'days must be between 1 and 16') 192 | .optional(), 193 | units: z.enum(['celsius', 'fahrenheit']).optional(), 194 | }); 195 | 196 | try { 197 | if (name === 'get_weather') { 198 | const parsed = GetWeatherSchema.safeParse(args ?? {}); 199 | if (!parsed.success) { 200 | return { 201 | content: [ 202 | { 203 | type: 'text', 204 | text: JSON.stringify({ 205 | error: 'Invalid arguments', 206 | tool: name, 207 | issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), 208 | }, null, 2), 209 | }, 210 | ], 211 | isError: true, 212 | }; 213 | } 214 | const { location, units = 'fahrenheit' } = parsed.data; 215 | 216 | let latitude, longitude, locationName; 217 | 218 | // Check if location is coordinates (lat,lon format) 219 | if (location.includes(',')) { 220 | const [lat, lon] = location.split(',').map(s => parseFloat(s.trim())); 221 | if (isNaN(lat) || isNaN(lon)) { 222 | throw new Error('Invalid coordinates format. Use "latitude,longitude" (e.g., "40.7128,-74.0060")'); 223 | } 224 | latitude = lat; 225 | longitude = lon; 226 | locationName = `${latitude}, ${longitude}`; 227 | } else { 228 | // Geocode city name 229 | const geocoded = await geocodeCity(location); 230 | latitude = geocoded.latitude; 231 | longitude = geocoded.longitude; 232 | locationName = `${geocoded.name}, ${geocoded.admin1 ? geocoded.admin1 + ', ' : ''}${geocoded.country}`; 233 | } 234 | 235 | // Get weather data 236 | const weatherData = await getWeatherData(latitude, longitude, { units }); 237 | const current = weatherData.current; 238 | 239 | const result = { 240 | location: locationName, 241 | coordinates: { latitude, longitude }, 242 | temperature: `${Math.round(current.temperature_2m)}°${units === 'celsius' ? 'C' : 'F'}`, 243 | feels_like: `${Math.round(current.apparent_temperature)}°${units === 'celsius' ? 'C' : 'F'}`, 244 | humidity: `${current.relative_humidity_2m}%`, 245 | wind: `${Math.round(current.wind_speed_10m)} mph ${getWindDirection(current.wind_direction_10m)}`, 246 | condition: getWeatherDescription(current.weather_code), 247 | precipitation: `${current.precipitation}" rain`, 248 | timestamp: current.time 249 | }; 250 | 251 | return { 252 | content: [ 253 | { 254 | type: 'text', 255 | text: JSON.stringify(result, null, 2) 256 | } 257 | ] 258 | }; 259 | 260 | } else if (name === 'get_forecast') { 261 | const parsed = GetForecastSchema.safeParse(args ?? {}); 262 | if (!parsed.success) { 263 | return { 264 | content: [ 265 | { 266 | type: 'text', 267 | text: JSON.stringify({ 268 | error: 'Invalid arguments', 269 | tool: name, 270 | issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), 271 | }, null, 2), 272 | }, 273 | ], 274 | isError: true, 275 | }; 276 | } 277 | const { location, days = 5, units = 'fahrenheit' } = parsed.data; 278 | 279 | let latitude, longitude, locationName; 280 | 281 | // Check if location is coordinates 282 | if (location.includes(',')) { 283 | const [lat, lon] = location.split(',').map(s => parseFloat(s.trim())); 284 | if (isNaN(lat) || isNaN(lon)) { 285 | throw new Error('Invalid coordinates format. Use "latitude,longitude"'); 286 | } 287 | latitude = lat; 288 | longitude = lon; 289 | locationName = `${latitude}, ${longitude}`; 290 | } else { 291 | // Geocode city name 292 | const geocoded = await geocodeCity(location); 293 | latitude = geocoded.latitude; 294 | longitude = geocoded.longitude; 295 | locationName = `${geocoded.name}, ${geocoded.admin1 ? geocoded.admin1 + ', ' : ''}${geocoded.country}`; 296 | } 297 | 298 | // Get forecast data 299 | const weatherData = await getWeatherData(latitude, longitude, { 300 | units, 301 | forecast_days: Math.min(Math.max(days, 1), 16) 302 | }); 303 | 304 | const daily = weatherData.daily; 305 | const forecast = daily.time.map((date, index) => ({ 306 | date, 307 | high: `${Math.round(daily.temperature_2m_max[index])}°${units === 'celsius' ? 'C' : 'F'}`, 308 | low: `${Math.round(daily.temperature_2m_min[index])}°${units === 'celsius' ? 'C' : 'F'}`, 309 | condition: getWeatherDescription(daily.weather_code[index]), 310 | precipitation: `${daily.precipitation_sum[index]}" rain` 311 | })); 312 | 313 | const result = { 314 | location: locationName, 315 | coordinates: { latitude, longitude }, 316 | forecast 317 | }; 318 | 319 | return { 320 | content: [ 321 | { 322 | type: 'text', 323 | text: JSON.stringify(result, null, 2) 324 | } 325 | ] 326 | }; 327 | 328 | } else { 329 | throw new Error(`Unknown tool: ${name}`); 330 | } 331 | 332 | } catch (error) { 333 | return { 334 | content: [ 335 | { 336 | type: 'text', 337 | text: JSON.stringify({ 338 | error: error.message, 339 | tool: name, 340 | arguments: args 341 | }, null, 2) 342 | } 343 | ], 344 | isError: true 345 | }; 346 | } 347 | }); 348 | 349 | // Helper function to convert wind direction degrees to cardinal direction 350 | function getWindDirection(degrees) { 351 | const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; 352 | const index = Math.round(degrees / 22.5) % 16; 353 | return directions[index]; 354 | } 355 | 356 | // Start the server 357 | async function main() { 358 | const transport = new StdioServerTransport(); 359 | await server.connect(transport); 360 | console.error('Weather MCP Server running...'); 361 | } 362 | 363 | main().catch(error => { 364 | console.error('Server error:', error); 365 | process.exit(1); 366 | }); 367 | -------------------------------------------------------------------------------- /src/daemon/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolveOrchestrator, 3 | deriveIdentityEnv, 4 | computeDaemonId, 5 | Orchestrator, 6 | } from './runtime.ts'; 7 | import fs from 'fs/promises'; 8 | import path from 'path'; 9 | import { spawn } from 'child_process'; 10 | 11 | /** 12 | * Common CLI options for daemon management commands. 13 | */ 14 | export interface DaemonCommandOptions { 15 | /** Working directory that scopes daemon identity and artifacts. */ 16 | cwd?: string; 17 | /** Environment variables to pass to the MCP server. */ 18 | env?: Record; 19 | /** Enable debug diagnostics. */ 20 | debug?: boolean; 21 | /** Request immediate start and OSLog streaming for logs-related commands. */ 22 | logs?: boolean; 23 | /** Daemon inactivity timeout in seconds. */ 24 | timeout?: number; 25 | /** Default tool execution timeout in milliseconds (front-facing). */ 26 | toolTimeoutMs?: number; 27 | /** Suppress non-essential output. */ 28 | quiet?: boolean; 29 | } 30 | 31 | /** 32 | * Ensure (and optionally start) a daemon for the provided server command. 33 | * 34 | * @param command MCP server executable. 35 | * @param args Arguments for the MCP server. 36 | * @param options Daemon command options (cwd, env, debug, etc.). 37 | * @returns A promise that resolves when ensuring is complete. 38 | */ 39 | export async function handleDaemonStart( 40 | command: string, 41 | args: string[], 42 | options: DaemonCommandOptions = {}, 43 | ): Promise { 44 | const cwd = options.cwd ?? process.cwd(); 45 | 46 | if (!command?.trim()) { 47 | console.error('Error: Command required to start daemon'); 48 | process.exit(1); 49 | } 50 | 51 | try { 52 | const orchestrator: Orchestrator = await resolveOrchestrator(); 53 | const identityEnv = deriveIdentityEnv(options.env ?? {}); 54 | const id = computeDaemonId(command, args, identityEnv); 55 | 56 | if (!options.quiet) { 57 | console.log(`Ensuring daemon (id ${id}) for: ${command} ${args.join(' ')}`); 58 | } 59 | 60 | const ensureRes = await orchestrator.ensure(command, args, { 61 | cwd, 62 | env: options.env ?? {}, 63 | debug: options.debug, 64 | logs: options.logs, 65 | timeout: options.timeout, // Pass seconds, runtime will convert to ms 66 | // Propagate tool timeout to wrapper via env (MCPLI_TOOL_TIMEOUT_MS) 67 | toolTimeoutMs: 68 | typeof options.toolTimeoutMs === 'number' && !isNaN(options.toolTimeoutMs) 69 | ? Math.max(1000, Math.trunc(options.toolTimeoutMs)) 70 | : undefined, 71 | preferImmediateStart: true, 72 | }); 73 | 74 | if (!options.quiet) { 75 | console.log(`Launchd job ensured.`); 76 | if (ensureRes.label) console.log(` Label: ${ensureRes.label}`); 77 | console.log(` ID: ${ensureRes.id}`); 78 | console.log(` Socket: ${ensureRes.socketPath}`); 79 | if (ensureRes.pid) console.log(` PID: ${ensureRes.pid}`); 80 | } 81 | } catch (error) { 82 | console.error(`Failed to ensure daemon: ${error instanceof Error ? error.message : error}`); 83 | process.exit(1); 84 | } 85 | } 86 | 87 | /** 88 | * Stop a specific daemon (if command/args provided) or all daemons in cwd. 89 | * 90 | * @param command Optional MCP server executable to compute a specific id. 91 | * @param args Arguments for the MCP server. 92 | * @param options Daemon command options. 93 | * @returns A promise that resolves when stop actions complete. 94 | */ 95 | export async function handleDaemonStop( 96 | command?: string, 97 | args: string[] = [], 98 | options: DaemonCommandOptions = {}, 99 | ): Promise { 100 | try { 101 | const orchestrator: Orchestrator = await resolveOrchestrator(); 102 | 103 | let id: string | undefined; 104 | if (command?.trim()) { 105 | const identityEnv = deriveIdentityEnv(options.env ?? {}); 106 | id = computeDaemonId(command, args, identityEnv); 107 | } 108 | 109 | await orchestrator.stop(id); 110 | if (!options.quiet) { 111 | console.log(id ? `Stopped daemon ${id}` : 'Stopped all daemons for this project'); 112 | } 113 | } catch (error) { 114 | console.error(`Failed to stop daemon: ${error instanceof Error ? error.message : error}`); 115 | process.exit(1); 116 | } 117 | } 118 | 119 | /** 120 | * Print status for all daemons managed under the current directory. 121 | * 122 | * @returns A promise that resolves when output is complete. 123 | */ 124 | export async function handleDaemonStatus(): Promise { 125 | try { 126 | const orchestrator: Orchestrator = await resolveOrchestrator(); 127 | const entries = await orchestrator.status(); 128 | 129 | if (entries.length === 0) { 130 | console.log('No daemons found in this directory'); 131 | return; 132 | } 133 | 134 | for (const s of entries) { 135 | console.log(`Daemon ${s.id}:`); 136 | console.log(` Label: ${s.label ?? '(unknown)'}`); 137 | console.log(` Loaded: ${s.loaded ? 'yes' : 'no'}`); 138 | console.log(` Running: ${s.running ? 'yes' : 'no'}`); 139 | if (s.pid) console.log(` PID: ${s.pid}`); 140 | if (s.socketPath) console.log(` Socket: ${s.socketPath}`); 141 | console.log(''); 142 | } 143 | } catch (error) { 144 | console.error(`Error reading daemon status: ${error instanceof Error ? error.message : error}`); 145 | process.exit(1); 146 | } 147 | } 148 | 149 | /** 150 | * Restart a specific daemon or all daemons for the current directory. 151 | * 152 | * @param command Optional MCP server executable for specific daemon. 153 | * @param args Arguments for the MCP server. 154 | * @param options Daemon command options. 155 | * @returns A promise that resolves when restart actions complete. 156 | */ 157 | export async function handleDaemonRestart( 158 | command?: string, 159 | args: string[] = [], 160 | options: DaemonCommandOptions = {}, 161 | ): Promise { 162 | if (!options.quiet) { 163 | console.log('Restarting daemon...'); 164 | } 165 | 166 | await handleDaemonStop(command, args, options); 167 | await new Promise((resolve) => setTimeout(resolve, 300)); 168 | 169 | if (command?.trim()) { 170 | await handleDaemonStart(command, args, options); 171 | } else { 172 | if (!options.quiet) { 173 | console.log('No specific command provided. Daemons will start on next usage.'); 174 | } 175 | } 176 | } 177 | 178 | /** 179 | * Remove orchestrator artifacts (plists, sockets) and attempt to clean `.mcpli`. 180 | * 181 | * @param options Daemon command options with cwd and quiet controls. 182 | * @returns A promise that resolves when cleanup completes. 183 | */ 184 | export async function handleDaemonClean(options: DaemonCommandOptions = {}): Promise { 185 | const cwd = options.cwd ?? process.cwd(); 186 | 187 | try { 188 | const orchestrator: Orchestrator = await resolveOrchestrator(); 189 | await orchestrator.clean(); 190 | 191 | // Attempt to remove .mcpli directory if empty 192 | const mcpliDir = path.join(cwd, '.mcpli'); 193 | try { 194 | await fs.rmdir(mcpliDir); 195 | if (!options.quiet) { 196 | console.log('Removed empty .mcpli directory'); 197 | } 198 | } catch { 199 | // ignore 200 | } 201 | 202 | if (!options.quiet) { 203 | console.log('Daemon cleanup complete'); 204 | } 205 | } catch (error) { 206 | console.error( 207 | `Failed to clean daemon files: ${error instanceof Error ? error.message : error}`, 208 | ); 209 | process.exit(1); 210 | } 211 | } 212 | 213 | /** 214 | * Stream daemon logs using OSLog filtering for specific daemon or all MCPLI daemons. 215 | */ 216 | /** 217 | * Stream daemon logs from macOS unified logging (OSLog) filtered to MCPLI. 218 | * 219 | * @param command Optional MCP server executable to filter logs for a specific daemon. 220 | * @param args Arguments for the MCP server. 221 | * @param options Daemon command options. 222 | * @returns A promise that resolves when the streaming ends. 223 | */ 224 | export async function handleDaemonLogs( 225 | command?: string, 226 | args: string[] = [], 227 | options: DaemonCommandOptions = {}, 228 | ): Promise { 229 | if (process.platform !== 'darwin') { 230 | console.error('Daemon logs are only available on macOS.'); 231 | process.exit(1); 232 | } 233 | 234 | let description: string; 235 | 236 | if (command?.trim()) { 237 | // Show logs for specific daemon 238 | const identityEnv = deriveIdentityEnv(options.env ?? {}); 239 | const id = computeDaemonId(command, args, identityEnv); 240 | description = `Streaming OSLog for daemon ${id} (${command} ${args.join(' ')}):`; 241 | } else { 242 | // Show logs for all MCPLI daemons 243 | description = 'Streaming OSLog for all MCPLI daemons:'; 244 | } 245 | 246 | console.log(description); 247 | console.log('Press Ctrl+C to exit\n'); 248 | 249 | // Use the correct predicate for streaming 250 | const predicate = command 251 | ? `eventMessage CONTAINS "[MCPLI:${computeDaemonId(command, args, deriveIdentityEnv(options.env ?? {}))}"` 252 | : `eventMessage CONTAINS "[MCPLI:"`; 253 | 254 | const proc = spawn('/usr/bin/log', ['stream', '--style', 'compact', '--predicate', predicate], { 255 | stdio: ['ignore', 'inherit', 'ignore'], 256 | }); 257 | 258 | // Handle Ctrl+C gracefully 259 | process.on('SIGINT', () => { 260 | proc.kill('SIGTERM'); 261 | process.exit(0); 262 | }); 263 | 264 | await new Promise((resolve, reject) => { 265 | proc.on('exit', (code) => { 266 | if (code === 0) resolve(); 267 | else reject(new Error(`log stream exited with code ${code}`)); 268 | }); 269 | proc.on('error', reject); 270 | }); 271 | } 272 | 273 | /** 274 | * Print help text for daemon subcommands. 275 | * 276 | * @returns Nothing; prints to stdout. 277 | */ 278 | export async function handleDaemonLogShow( 279 | command?: string, 280 | args: string[] = [], 281 | options: DaemonCommandOptions = {}, 282 | since: string = '2m', 283 | ): Promise { 284 | if (process.platform !== 'darwin') { 285 | console.error('Daemon logs are only available on macOS.'); 286 | process.exit(1); 287 | } 288 | 289 | const sinceFinal = 290 | process.env.MCPLI_LOG_SINCE && process.env.MCPLI_LOG_SINCE.trim() !== '' 291 | ? (process.env.MCPLI_LOG_SINCE as string) 292 | : since; 293 | 294 | let predicate: string; 295 | if (command?.trim()) { 296 | const identityEnv = deriveIdentityEnv(options.env ?? {}); 297 | const id = computeDaemonId(command, args, identityEnv); 298 | predicate = `eventMessage CONTAINS "[MCPLI:${id}]"`; 299 | } else { 300 | predicate = `eventMessage CONTAINS "[MCPLI:"`; 301 | } 302 | 303 | const proc = spawn( 304 | '/usr/bin/log', 305 | ['show', '--style', 'compact', '--last', sinceFinal, '--predicate', predicate], 306 | { 307 | stdio: ['ignore', 'inherit', 'inherit'], 308 | }, 309 | ); 310 | 311 | await new Promise((resolve, reject) => { 312 | proc.on('exit', (code) => { 313 | if (code === 0) resolve(); 314 | else reject(new Error(`log show exited with code ${code}`)); 315 | }); 316 | proc.on('error', reject); 317 | }); 318 | } 319 | 320 | export function printDaemonHelp(): void { 321 | console.log('MCPLI Daemon Management'); 322 | console.log(''); 323 | console.log('Usage:'); 324 | console.log(' mcpli daemon [options]'); 325 | console.log(''); 326 | console.log('Commands:'); 327 | console.log(' start [-- command args...] Start daemon with MCP server command'); 328 | console.log(' stop [-- command args...] Stop specific daemon or all daemons'); 329 | console.log(' restart [-- command args...] Restart specific daemon or all daemons'); 330 | console.log(' status Show all running daemons'); 331 | console.log(' logs Show daemon log output'); 332 | console.log(' log [--since=2m] Show recent daemon logs (non-interactive)'); 333 | console.log(' clean Clean up all daemon files'); 334 | console.log(''); 335 | console.log('Options:'); 336 | console.log(' --debug Enable debug output'); 337 | console.log(' --quiet, -q Suppress informational output'); 338 | console.log(' --timeout= Set daemon inactivity timeout (seconds)'); 339 | console.log(' --tool-timeout= Set default tool execution timeout (seconds)'); 340 | console.log(''); 341 | console.log('Notes:'); 342 | console.log(' - Daemons are command-specific per directory using stable daemon IDs'); 343 | console.log(' - Commands auto-start daemons transparently (no manual start needed)'); 344 | console.log(' - stop/restart without command acts on all daemons in directory'); 345 | console.log(''); 346 | console.log('Examples:'); 347 | console.log(' mcpli daemon start -- node server.js'); 348 | console.log(' mcpli daemon status'); 349 | console.log(' mcpli daemon stop'); 350 | console.log(' mcpli daemon clean'); 351 | } 352 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![MCPLI Logo](banner.png) 2 | 3 | [![CI](https://github.com/cameroncooke/mcpli/actions/workflows/ci.yml/badge.svg)](https://github.com/cameroncooke/mcpli/actions/workflows/ci.yml) 4 | [![npm version](https://badge.fury.io/js/mcpli.svg)](https://badge.fury.io/js/mcpli) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![macOS](https://img.shields.io/badge/platform-macOS-lightgrey.svg)](https://www.apple.com/macos/) 7 | [![pkg.pr.new](https://pkg.pr.new/badge/cameroncooke/mcpli)](https://pkg.pr.new/~/cameroncooke/mcpli) 8 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/cameroncooke/mcpli) 9 | 10 | # MCPLI 11 | 12 | Transform stdio-based MCP servers into a first‑class CLI tool. 13 | 14 | MCPLI turns any stdio-based MCP server into a discoverable, script‑friendly, agent-friendly CLI. Run tools as natural commands, compose the output using standard bash tools. 15 | 16 | ## Table of contents 17 | 18 | - [Quick Start](#quick-start) 19 | - [Features](#features) 20 | - [Why MCPLI?](#why-mcpli) 21 | - [Installation](#installation) 22 | - [Requirements](#requirements) 23 | - [Global Installation](#global-installation) 24 | - [Direct Usage (No Installation)](#direct-usage-no-installation) 25 | - [Usage](#usage) 26 | - [Discover and Run Tools](#discover-and-run-tools) 27 | - [Output Modes](#output-modes) 28 | - [Examples](#examples) 29 | - [Advanced Features](#advanced-features) 30 | - [Process Management](#process-management) 31 | - [Multiple Daemons](#multiple-daemons) 32 | - [Daemon Timeouts](#daemon-timeouts) 33 | - [Environment Variables](#environment-variables) 34 | - [Debugging](#debugging) 35 | - [Tool Parameter Syntax](#tool-parameter-syntax) 36 | - [Contributing](#contributing) 37 | - [Security](#security) 38 | - [License](#license) 39 | 40 | ## Quick Start 41 | 42 | ```bash 43 | # Install globally via npm 44 | npm install -g mcpli 45 | 46 | # View help 47 | mcpli --help -- node weather-server.js 48 | 49 | # View tool help 50 | mcpli get-weather --help -- node weather-server.js 51 | 52 | # Run a tool 53 | mcpli get-weather --location "San Francisco" -- node weather-server.js 54 | ``` 55 | 56 | ## Features 57 | 58 | - Zero setup – Point at any stdio-based MCP server, get instant CLI access 59 | - Maintains a persistent daemon to ensure MCP is stateful and same instance is reused for repeated calls 60 | - Natural syntax – Tools become commands: `mcpli get-weather --location "NYC" -- node weather-server.js` 61 | - Auto‑generated help – `mcpli --help -- ` lists tools; `mcpli --help -- ` shows parameters 62 | - Clean output – Structured JSON that’s great for shell pipelines 63 | - Flexible parameters – Supports `--key value` and `--key=value`, plus JSON for arrays/objects 64 | - Cancellation – Ctrl+C cancels the active tool request; the daemon remains running 65 | 66 | 67 | ## Why MCPLI? 68 | 69 | MCP gives us a standard way for agents to talk to tools. But at scale it hits a hard limit: context and composability. Each MCP server brings tool descriptions, schemas, and metadata that the agent needs to ingest. Install a few servers with dozens of tools and you're burning tens of thousands of tokens before you've processed a single user prompt. That cost isn't just financial—it steals headroom for reasoning and hurts reliability. 70 | 71 | This creates a false choice for developers: 72 | - Use MCP "as intended" and accept huge, inference‑time context overhead and limited shell‑level composition. 73 | - Abandon MCP and wrap CLI tools instead, curating concise AGENTS.md examples that are efficient, predictable, and composable—but give up MCP's standardisation. 74 | 75 | MCPLI bridges that gap. It turns any stdio‑based MCP server into a first‑class CLI tool, so you get: 76 | - The best of both worlds: keep your MCP servers and their capability model, but exercise them as predictable CLI commands. 77 | - Composability by default: pipe results to jq, grep, awk, curl, or anything else your workflow demands. 78 | - Control over context: stop auto‑injecting large tool schemas; instead provide tight, example‑driven prompts just like you would for any other CLI. 79 | - Standard help and discoverability: mcpli auto‑generates tool help from MCP schemas, so your CLI stays self‑documenting and consistent. 80 | - Preservation of existing investments: no server changes required—MCPLI instantly upgrades what you already have. 81 | 82 | What changes in practice 83 | - Your agent no longer has to ingest every tool schema up front. You feed it small, curated examples: `mcpli get-weather --location 'NYC' -- node weather-server.js`. 84 | - You regain the shell: `mcpli get-weather ... | jq .temperature` is now trivial and reliable. 85 | - You keep state and speed with a persistent daemon behind the scenes, but you interact through a simple, composable CLI surface. 86 | 87 | Bottom line 88 | MCPLI doesn't replace MCP. It completes it. Keep the standard, ditch the token bloat, and gain the composability and predictability of the command line without rewriting your servers or your workflows. 89 | 90 | ## Installation 91 | 92 | ### Requirements 93 | 94 | - Node.js 18+ 95 | - Any MCP‑compliant server that uses stdio-based transport 96 | - macOS (launchd) for daemon orchestration 97 | 98 | ### Global Installation 99 | ```bash 100 | npm install -g mcpli 101 | ``` 102 | 103 | ### Direct Usage (No Installation) 104 | ```bash 105 | npx mcpli@latest -- [args...] 106 | ``` 107 | 108 | ## Usage 109 | 110 | Always include the MCP server command after -- on every invocation. MCPLI does the rest for you. 111 | 112 | ### Discover and Run Tools 113 | 114 | View tools: 115 | 116 | ```bash 117 | mcpli --help -- [args...] 118 | ``` 119 | 120 | View tool help: 121 | 122 | ```bash 123 | mcpli --help -- [args...] 124 | ``` 125 | 126 | Basic tool call: 127 | 128 | ```bash 129 | npx mcpli@latest [tool-options...] -- [args...] 130 | ``` 131 | 132 | ### Output Modes 133 | 134 | ```bash 135 | # Default: Friendly extraction of tool result content 136 | mcpli get-weather --location "NYC" -- node weather-server.js 137 | 138 | # Raw MCP response for debugging 139 | mcpli get-weather --location "NYC" --raw -- node weather-server.js 140 | ``` 141 | 142 | ### Examples 143 | 144 | ```bash 145 | # Discover available tools from a server 146 | > mcpli --help -- node weather-server.js 147 | 148 | Usage: 149 | mcpli [tool-options...] -- [args...] 150 | mcpli --help -- [args...] 151 | mcpli --help -- [args...] 152 | mcpli daemon [options] 153 | 154 | Global Options: 155 | --help, -h Show this help and list all available tools 156 | --verbose Show MCP server output (stderr/logs) 157 | --raw Print raw MCP response 158 | --debug Enable debug output 159 | --timeout= Set daemon inactivity timeout (default: 1800) 160 | 161 | Available Tools: 162 | get-weather Get current weather information for any location 163 | get-forecast Get weather forecast for multiple days 164 | 165 | Tool Help: 166 | mcpli --help -- Show detailed help for specific tool 167 | 168 | Daemon Commands: 169 | daemon start Start long-lived daemon process 170 | daemon stop Stop daemon process 171 | daemon status Show daemon status 172 | daemon restart Restart daemon process 173 | daemon logs Show daemon logs 174 | daemon log Show recent daemon logs (non-interactive) 175 | daemon clean Clean up daemon files 176 | 177 | Examples: 178 | mcpli get-weather --help -- node weather-server.js 179 | mcpli get-weather --option value -- node weather-server.js 180 | ``` 181 | 182 | ```bash 183 | # Get tool-specific help 184 | > mcpli get-weather --help -- node weather-server.js 185 | 186 | MCPLI Tool: get-weather 187 | 188 | Description: Get current weather information for any location 189 | 190 | Usage: mcpli get-weather [options] -- [args...] 191 | 192 | Options: 193 | --location (string) [required] City name (e.g., "New York", "London, UK") or coordinates as "lat,lon" 194 | --units (string) Temperature units (default: "fahrenheit") 195 | 196 | Examples: 197 | mcpli get-weather --help -- node weather-server.js 198 | mcpli get-weather --location "example-value" -- node weather-server.js 199 | ``` 200 | 201 | ```bash 202 | # Run a tool 203 | > mcpli get-weather --location "San Francisco" -- node weather-server.js 204 | 205 | { 206 | "location": "San Francisco, California, United States", 207 | "coordinates": { 208 | "latitude": 37.77493, 209 | "longitude": -122.41942 210 | }, 211 | "temperature": "63°F", 212 | "feels_like": "64°F", 213 | "humidity": "86%", 214 | "wind": "4 mph WSW", 215 | "condition": "Partly cloudy", 216 | "precipitation": "0\" rain", 217 | "timestamp": "2025-08-24T10:00" 218 | } 219 | ``` 220 | 221 | ```bash 222 | # Compose the output 223 | > mcpli get-weather --location "San Francisco" -- node weather-server.js | jq -r '.temperature' 224 | 225 | 63°F 226 | ``` 227 | 228 | ## Advanced Features 229 | 230 | For advanced users who want more control, you can control the behavior of MCPLI with the following commands. 231 | 232 | ### Process Management 233 | 234 | MCPLI automatically maintains a persistent daemon to ensure your MCP server is stateful and that the same instance is reused for repeated calls. 235 | 236 | You can manually manage the daemon with the following commands. 237 | 238 | ```bash 239 | # Start explicitly 240 | mcpli daemon start -- node weather-server.js 241 | 242 | # Show status (current directory) 243 | mcpli daemon status 244 | 245 | # Stop a specific configuration 246 | mcpli daemon stop -- node weather-server.js 247 | 248 | # Stop everything in this directory 249 | mcpli daemon stop 250 | 251 | # Restart one or all 252 | mcpli daemon restart -- node weather-server.js 253 | mcpli daemon restart 254 | 255 | # View logs (live stream) 256 | mcpli daemon logs 257 | 258 | # Show recent logs (non-interactive; default 2 minutes) 259 | mcpli daemon log --since=2m 260 | # Filter to specific daemon by passing the server command after -- 261 | mcpli daemon log --since=2m -- node weather-server.js 262 | 263 | # Clean up files and stale sockets 264 | mcpli daemon clean 265 | ``` 266 | 267 | #### Multiple Daemons 268 | 269 | MCPLI creates separate daemon processes for each unique combination of command + arguments + environment variables. Each daemon gets its own identity based on a hash of these components. 270 | 271 | ```bash 272 | # These create different daemons (different commands) 273 | mcpli daemon start -- node weather-server.js 274 | mcpli daemon start -- python weather.py 275 | 276 | # These create different daemons (different args) 277 | mcpli daemon start -- node weather-server.js --port 3000 278 | mcpli daemon start -- node weather-server.js --port 4000 279 | 280 | # These create different daemons (different env) 281 | mcpli daemon start -- API_KEY=dev node weather-server.js 282 | mcpli daemon start -- API_KEY=prod node weather-server.js 283 | ``` 284 | 285 | Each daemon exposes a Unix socket at `$TMPDIR/mcpli//.sock` (printed by `mcpli daemon status`): 286 | - **Socket file**: `$TMPDIR/mcpli//.sock` — Unix socket for IPC communication 287 | 288 | The hash ensures that identical configurations reuse the same daemon, while different configurations get separate processes. This allows you to run multiple MCP servers simultaneously without conflicts. 289 | 290 | Daemon lifecycle is managed by macOS launchd — no lock files are used. 291 | 292 | > [!NOTE] 293 | > It's recommended to add `.mcpli/` to your `.gitignore` file to avoid committing launchd plist metadata and diagnostics. Socket files live under `$TMPDIR` and are not part of your repository. 294 | 295 | **Shell environment does not affect daemon identity** 296 | - Only environment variables provided after -- as KEY=VALUE tokens (CommandSpec env) are considered when computing the daemon ID. 297 | - Setting env before mcpli runs does not create a distinct daemon. 298 | 299 | Examples: 300 | ```bash 301 | # Different daemons (different CommandSpec env) 302 | mcpli daemon start -- API_KEY=dev node weather-server.js 303 | mcpli daemon start -- API_KEY=prod node weather-server.js 304 | 305 | # Same daemon (shell env not considered for identity) 306 | API_KEY=dev mcpli daemon start -- node weather-server.js 307 | mcpli daemon start -- node weather-server.js 308 | ``` 309 | 310 | #### Timeouts Overview 311 | 312 | MCPLI uses two primary user-facing timeouts: 313 | 314 | - Daemon inactivity timeout: how long an idle daemon stays alive. 315 | - Tool execution timeout: how long a single MCP tool call may run. 316 | 317 | IPC is managed automatically and will be set to at least tool timeout + 60 seconds to avoid premature disconnections. 318 | 319 | #### Daemon Timeouts 320 | 321 | Daemons automatically shut down after a period of inactivity to free up resources (default: 30 minutes). You can customize this timeout: 322 | 323 | ```bash 324 | # Set a 5-minute timeout (300 seconds) 325 | mcpli get-weather --timeout=300 --location "NYC" -- node weather-server.js 326 | 327 | # Set a 1-hour timeout (3600 seconds) for long-running sessions 328 | mcpli daemon start --timeout=3600 -- node weather-server.js 329 | ``` 330 | 331 | The timeout resets every time you make a request to the daemon. Once the timeout period passes without any activity, the daemon gracefully shuts down and cleans up its files. 332 | 333 | Note on preserving timeouts: 334 | - When you do not pass `--timeout`, MCPLI preserves the daemon's existing inactivity timeout across ensures and restarts (avoids unnecessary plist reloads). 335 | - Passing `--timeout=` explicitly overrides the preserved value for that daemon. 336 | 337 | #### Tool Timeouts 338 | 339 | Control how long a single MCP tool call may run (default: 10 minutes). 340 | 341 | ```bash 342 | # Per-invocation: set tool timeout to 15 minutes (900 seconds) 343 | mcpli get-weather --tool-timeout=900 --location "NYC" -- node weather-server.js 344 | 345 | # Global default via env (milliseconds) 346 | export MCPLI_TOOL_TIMEOUT_MS=900000 347 | ``` 348 | 349 | Notes: 350 | - `--tool-timeout` uses seconds, matching `--timeout` for consistency. 351 | - `MCPLI_TOOL_TIMEOUT_MS` is the env for defaults. 352 | - IPC timeout automatically adjusts to ≥ tool timeout + 60s for tool calls. 353 | 354 | ### Environment Variables 355 | 356 | You can set default timeouts using environment variables instead of passing `--timeout` on every command: 357 | 358 | ```bash 359 | # Set default daemon timeout to 10 minutes (600 seconds) 360 | export MCPLI_DEFAULT_TIMEOUT=600 361 | 362 | # Now all commands use this timeout by default 363 | mcpli get-weather --location "NYC" -- node weather-server.js 364 | 365 | # You can still override with --timeout argument 366 | mcpli get-weather --timeout=1200 --location "NYC" -- node weather-server.js 367 | ``` 368 | 369 | Available environment variables: 370 | - `MCPLI_DEFAULT_TIMEOUT`: Daemon inactivity timeout in seconds (default: 1800) 371 | - `MCPLI_TOOL_TIMEOUT_MS`: Default tool execution timeout in milliseconds (default: 600000) 372 | - `MCPLI_IPC_TIMEOUT`: IPC connection timeout in milliseconds (default: 660000). For advanced tuning; generally unnecessary due to auto-buffering. 373 | - `MCPLI_IPC_CONNECT_RETRY_BUDGET_MS`: Advanced — total time budget for retrying the initial socket connect after `orchestrator.ensure()` (default: 3000). The client automatically raises the effective budget to ~8000ms on the first connection after a launchd plist update (job `loaded`/`reloaded`) to ride out transient socket rebinds. In steady‑state, this env sets the baseline. 374 | - `MCPLI_TIMEOUT`: Internal wrapper inactivity timeout (ms); derived from CLI `--timeout` or `MCPLI_DEFAULT_TIMEOUT` (default: 1800000). Typically not set directly. 375 | 376 | Deprecated/unused: 377 | - `MCPLI_CLI_TIMEOUT`: Reserved for future use; not currently enforced. 378 | 379 | ### Debugging 380 | 381 | - `--debug` enables MCPLI diagnostics (structured, concise) 382 | - `--verbose` shows MCP server stderr/logs 383 | - `mcpli daemon log [--since=2m] [-- ]` prints recent logs without streaming 384 | - Default window can be configured via `MCPLI_LOG_SINCE` (e.g., `export MCPLI_LOG_SINCE=10m`) 385 | 386 | ## Tool Parameter Syntax 387 | 388 | - Strings: `--location "San Francisco"` 389 | - Numbers: `--count 42` or `--rating -123.456` 390 | - Booleans: `--enabled` or `--debug false` 391 | - Arrays: `--tags='["web","api"]'` 392 | - Objects: `--config='{"timeout":5000}'` 393 | 394 | Both `--key value` and `--key=value` work for all types. 395 | 396 | ## Contributing 397 | 398 | We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on: 399 | 400 | - Setting up the development environment 401 | - Understanding the codebase architecture 402 | - Making changes and submitting pull requests 403 | - Code quality standards and testing guidelines 404 | 405 | Whether you're fixing bugs, adding features, improving documentation, or enhancing performance, your contributions help make MCPLI better for everyone. 406 | 407 | ## Security 408 | 409 | For a high‑level overview of MCPLI’s security posture, see the [Security Overview](security.md). 410 | 411 | ## License 412 | 413 | [MIT](LICENSE) 414 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # MCPLI Architecture 2 | 3 | MCPLI turns any MCP server into a first‑class CLI tool with a fast, seamless experience. It uses long‑lived daemon processes managed by macOS launchd with socket activation for optimal performance and reliability. This document explains the current architecture and how each component works together. 4 | 5 | **Note**: This is a macOS-only architecture using launchd for process management. 6 | 7 | ## Table of Contents 8 | 9 | 1. [System Overview](#system-overview) 10 | 2. [Architecture Principles](#architecture-principles) 11 | 3. [Core Components](#core-components) 12 | 4. [Daemon Lifecycle Management](#daemon-lifecycle-management) 13 | 5. [macOS launchd Integration](#macos-launchd-integration) 14 | 6. [Socket Activation Implementation](#socket-activation-implementation) 15 | 7. [Environment and Identity Management](#environment-and-identity-management) 16 | 8. [IPC Communication](#ipc-communication) 17 | 9. [Configuration System](#configuration-system) 18 | 10. [Performance Characteristics](#performance-characteristics) 19 | 11. [Security Model](#security-model) 20 | 12. [Key Components and Code References](#key-components-and-code-references) 21 | 22 | ## System Overview 23 | 24 | MCPLI (Model Context Protocol CLI) is a TypeScript-based command-line interface that transforms stdio-based MCP (Model Context Protocol) servers into persistent, high-performance command-line tools. The system architecture is built around daemon processes that maintain long-lived connections to MCP servers, enabling rapid tool execution with sub-100ms response times. 25 | 26 | The system operates on the principle of **daemon-per-server-configuration**, where each unique combination of MCP server command, arguments, and environment variables spawns a dedicated daemon process. This ensures complete isolation between different server configurations while maximizing reuse for identical configurations. 27 | 28 | ## Architecture Principles 29 | 30 | ### 1. Persistence-First Design 31 | MCPLI prioritizes persistent daemon processes over stateless execution. Every tool invocation attempts to use or create a daemon, ensuring consistent performance and state management. 32 | 33 | ### 2. Process Isolation 34 | Each daemon manages exactly one MCP server process, creating a clean 1:1 relationship that simplifies error handling and resource management. 35 | 36 | ### 3. Identity-Based Daemon Management 37 | Daemon identity is computed using SHA-256 hashing of the normalized server command, arguments, and environment variables, ensuring deterministic daemon selection. 38 | 39 | ### 4. Zero-Configuration Operation 40 | The system automatically handles daemon creation, process management, and cleanup without requiring user configuration or manual process management. 41 | 42 | ### 5. macOS-Native Integration 43 | Deep integration with macOS launchd provides robust process management, automatic respawning, and system-level socket activation. 44 | 45 | ## Core Components 46 | 47 | ### Entry Point (`src/mcpli.ts`) 48 | 49 | The main entry point handles command-line argument parsing and orchestrates the execution flow. 50 | 51 | Key responsibilities: 52 | - Command-line argument parsing with `--` separator handling 53 | - Tool method validation against available MCP server tools 54 | - Options processing (timeout, debug flags, output formatting) 55 | - Error message formatting and user feedback 56 | 57 | ### Daemon Client (`src/daemon/client.ts`) 58 | 59 | The daemon client manages communication with daemon processes through the launchd orchestrator. 60 | 61 | The client implements a streamlined daemon lifecycle management system: 62 | 63 | 1. **Single-request connections**: The client opens one Unix socket connection per request and closes it after the response. No connection pooling. 64 | 2. **No preflight checks**: The client does not ping before sending the request. It relies on launchd to spawn the daemon on first connection if needed. 65 | 3. **Orchestrator.ensure**: ensure() creates or updates the launchd plist and socket for the daemon identity and returns the socket path. It does not restart the daemon unless explicitly requested. 66 | 4. **preferImmediateStart=false**: The client requests ensure() with preferImmediateStart=false to avoid kickstarting on every request, eliminating the previous 10+ second delays caused by restarts. 67 | 5. **Adaptive Connect Retry Budget**: When `orchestrator.ensure()` indicates the job was just `loaded` or `reloaded` (or explicitly `started`), the client temporarily increases the IPC connect retry budget to ~8 seconds. This smooths over the brief socket rebind window under launchd after a plist update, preventing transient `ECONNREFUSED`. In steady-state (no update), a short default budget (~3s) is used. 68 | 6. **IPC Timeout Auto‑Buffering**: For tool calls, IPC timeout is automatically set to at least `(tool timeout + 60s)` to ensure the transport timeout never undercuts tool execution timeout. 69 | 7. **Request Cancellation**: Tool calls support cancellation via `AbortSignal`. On abort, the client issues a protocol‑level cancel for the matching request id; the daemon aborts that request while remaining online. 70 | 71 | ### Daemon Wrapper (`src/daemon/wrapper.ts`) 72 | 73 | The daemon wrapper runs as the long-lived daemon process and manages the MCP server connection. 74 | 75 | Core daemon functionality: 76 | - **MCP Server Management**: Spawns and maintains stdio connection to MCP server 77 | - **IPC Server**: Handles Unix domain socket communication from clients 78 | - **Request Processing**: Translates IPC requests to MCP JSON-RPC calls 79 | - **Lifecycle Management**: Handles graceful shutdown and error recovery 80 | - **Inactivity Management**: Automatic shutdown after configurable timeout 81 | - **Shutdown Protection**: A daemon-wide allowShutdown flag prevents accidental exits during normal operation 82 | - **Signal Handling**: SIGTERM and SIGINT trigger a graceful shutdown sequence, closing the IPC server and MCP client cleanly 83 | 84 | ## Daemon Lifecycle Management 85 | 86 | ### Daemon Identity and Uniqueness 87 | 88 | Daemon identity is computed using a deterministic hashing algorithm that ensures identical server configurations share the same daemon process. 89 | 90 | The identity computation process: 91 | 92 | 1. **Command Normalization**: Converts relative paths to absolute, handles platform differences 93 | 2. **Argument Processing**: Filters empty arguments, maintains order 94 | 3. **Environment Sorting**: Creates deterministic key-value ordering 95 | 4. **JSON Serialization**: Combines all components into consistent format 96 | 5. **SHA-256 Hashing**: Generates cryptographic hash of the serialized data 97 | 6. **ID Truncation**: Uses first 8 characters for human-readable daemon IDs 98 | 99 | **Environment inclusion**: Only environment variables explicitly provided after the CLI `--` (i.e., as part of the MCP server command definition) are included in the identity hash. MCPLI_* variables and the caller's shell environment do not affect the daemon identity. 100 | 101 | **Label format**: Launchd service labels follow `com.mcpli..`, where cwdHash is an 8-character SHA-256 hash of the absolute working directory. 102 | 103 | **Socket path**: Sockets are created under the macOS `$TMPDIR` base to avoid AF_UNIX limits: `$TMPDIR/mcpli//.sock` (typically `/var/folders/.../T/mcpli//.sock`). 104 | 105 | ### Process Spawning and Management 106 | 107 | The spawning process implements launchd-based lifecycle management: 108 | - **No lock files**: launchd manages daemon lifecycle tied to a socket. 109 | - **On-demand startup**: With preferImmediateStart=false, the client does not kickstart the job; the first socket connection activates the daemon if it isn't already running. 110 | - **No unconditional restarts**: ensure() never restarts an already-running daemon unless explicitly requested. 111 | 112 | ## macOS launchd Integration 113 | 114 | ### launchd Service Architecture 115 | 116 | MCPLI leverages macOS launchd for robust daemon process management and automatic service recovery. 117 | 118 | ### Property List (Plist) Configuration 119 | 120 | Each daemon requires a launchd property list file that defines the service configuration with the label format `com.mcpli..` and socket activation configured for fast startup. 121 | 122 | Key configuration elements: 123 | - **Label**: `com.mcpli..` 124 | - **ProgramArguments**: Path to daemon wrapper executable 125 | - **EnvironmentVariables**: Complete environment for daemon execution including MCPLI_TIMEOUT in milliseconds 126 | - **Sockets**: Socket activation configuration with file paths and permissions 127 | - **KeepAlive**: `{ SuccessfulExit: false }` to avoid keeping the job alive after clean exit; launchd will start it on the next socket connection 128 | - **ProcessType**: Background designation for system resource management 129 | 130 | ## Socket Activation Implementation 131 | 132 | ### Modern Socket Activation Architecture 133 | 134 | MCPLI implements modern macOS socket activation using the `launch_activate_socket` API through the `socket-activation` npm package. 135 | 136 | The implementation handles several critical aspects: 137 | 138 | 1. **FD Collection**: Uses `socket-activation` package to retrieve inherited file descriptors 139 | 2. **Validation**: Ensures at least one socket FD is available from launchd 140 | 3. **Server Creation**: Creates Node.js net.Server instance from inherited FD 141 | 4. **Required in launchd mode**: The daemon strictly uses the socket-activation package to collect inherited FDs. If no FDs are available for the configured socket name, startup fails rather than falling back to a non-activated socket. 142 | 143 | ## Environment and Identity Management 144 | 145 | ### Environment Variable Processing 146 | 147 | MCPLI implements sophisticated environment variable handling to ensure proper daemon isolation while supporting flexible server configuration. 148 | 149 | ### Identity Hash Computation 150 | 151 | The daemon identity system ensures that functionally identical server configurations share daemon processes while maintaining complete isolation between different configurations. 152 | 153 | The normalization process handles several important cases: 154 | - **Path Resolution**: Converts path-like command inputs to absolute paths; bare executables remain unchanged 155 | - **Environment Ordering**: Ensures deterministic hash generation regardless of variable order 156 | - **Empty Value Handling**: Includes empty-string environment values if explicitly provided 157 | - **Environment scope**: Only environment variables explicitly supplied as part of the MCP server command (after `--`) are considered for identity hashing. Ambient shell env is ignored. MCPLI_* variables are included only if passed after `--`. 158 | 159 | ## IPC Communication 160 | 161 | MCPLI uses a simple newline-delimited JSON protocol over Unix domain sockets. 162 | 163 | The IPC protocol uses newline-delimited JSON over Unix domain sockets: 164 | 165 | **Request Format:** 166 | ```json 167 | { 168 | "id": "unique-request-id", 169 | "method": "callTool|listTools|ping", 170 | "params": { /* method-specific parameters */ } 171 | } 172 | ``` 173 | 174 | **Response Format:** 175 | ```json 176 | { 177 | "id": "matching-request-id", 178 | "result": { /* method response */ }, 179 | "error": "error message if failed" 180 | } 181 | ``` 182 | 183 | **Processing Flow Notes:** 184 | - No preflight ping is performed; a single request/response connection is used. 185 | - The client does not kickstart the job; launchd activation on connect is relied upon. 186 | - Cancellation is request‑scoped. The client sends `cancelCall` for the request id; the daemon aborts the matching request without affecting other requests or the daemon lifecycle. 187 | 188 | ## Configuration System 189 | 190 | MCPLI uses a centralized configuration system (src/config.ts) that provides environment variable support and sensible defaults for all timeout values. 191 | 192 | ### Configuration Priority (highest to lowest): 193 | 1. **CLI arguments** (--timeout=300) 194 | 2. **Environment variables** (MCPLI_DEFAULT_TIMEOUT=600) 195 | 3. **Built-in defaults** (1800 seconds) 196 | 197 | ### Environment Variables: 198 | - `MCPLI_DEFAULT_TIMEOUT`: Daemon inactivity timeout in seconds 199 | - `MCPLI_TOOL_TIMEOUT_MS`: Default tool execution timeout in milliseconds (preferred) 200 | - `MCPLI_IPC_TIMEOUT`: IPC connection timeout in milliseconds (auto-buffer ≥ tool+60s) 201 | - `MCPLI_TIMEOUT`: Internal daemon wrapper timeout in milliseconds; derived from CLI `--timeout` or `MCPLI_DEFAULT_TIMEOUT` (default: 1800000) 202 | 203 | ## Performance Characteristics 204 | 205 | ### Execution Time Analysis 206 | 207 | MCPLI's performance profile demonstrates significant advantages of the daemon-based architecture: 208 | 209 | Performance benefits: 210 | - **95% Reduction**: Warm execution times are ~95% faster than cold starts 211 | - **Consistency**: Minimal variance in warm execution times (±5ms) 212 | - **Scalability**: Performance remains constant regardless of execution count 213 | - **Memory Efficiency**: Shared daemon processes reduce system memory usage 214 | 215 | **Measured warm-execution performance** (Apple Silicon, Node 18+): 216 | - **Simple echo tool**: 60–63ms end-to-end 217 | - **Network-bound weather tool**: ~316ms end-to-end (dominated by external API latency) 218 | 219 | ### Resource Utilization Profile 220 | 221 | Resource characteristics: 222 | - **Memory Footprint**: ~15-30MB per daemon process (varies by MCP server) 223 | - **CPU Usage**: Minimal during idle, spikes only during active tool execution 224 | - **File Descriptors**: 3-5 FDs per daemon (socket, pipes, log files) 225 | - **Disk Space**: <1MB per daemon for plist files, sockets, and logs 226 | 227 | ### Concurrent Execution Scaling 228 | 229 | Concurrency characteristics: 230 | - **Multiple clients can connect concurrently** to the same daemon via the Unix socket 231 | - **Requests are handled per connection**. The MCP SDK handles JSON-RPC concurrency; MCPLI does not apply explicit request serialization in the wrapper 232 | - **Load Distribution**: Multiple daemon types can run simultaneously for different server configurations 233 | 234 | ## Security Model 235 | 236 | ### Security Features 237 | 238 | 1. **File System Security**: 239 | - Unix domain sockets with 0600 permissions (owner-only access) 240 | - Plist files in user-specific directories 241 | - Temporary files with restricted permissions 242 | 243 | 2. **Process Security**: 244 | - Daemon processes run under user credentials only 245 | - No privilege escalation or system-level access 246 | - Complete process isolation between different daemon instances 247 | 248 | 3. **Communication Security**: 249 | - Local Unix sockets only (no network exposure) 250 | - Process-to-process communication without external access 251 | - Request/response validation and sanitization 252 | 253 | 4. **Environment Security**: 254 | - Environment variable filtering prevents sensitive data leakage 255 | - Controlled environment inheritance for MCP servers 256 | - No automatic environment variable propagation 257 | 258 | ## Key Components and Code References 259 | 260 | **High‑level CLI**: 261 | - src/mcpli.ts - Entry point with argument parsing and tool discovery 262 | 263 | **Daemon management and client**: 264 | - src/daemon/client.ts - DaemonClient with IPC communication 265 | - src/daemon/wrapper.ts - MCPLIDaemon process wrapper 266 | 267 | **Orchestration and daemon identity**: 268 | - src/daemon/runtime.ts - Orchestrator interface and identity functions including: 269 | - normalizeCommand(), normalizeEnv() 270 | - computeDaemonId(command, args, env) 271 | - deriveIdentityEnv(), isValidDaemonId() 272 | - Orchestrator interface with ensure(), stop(), status(), clean() 273 | 274 | **Launchd orchestrator**: 275 | - src/daemon/runtime-launchd.ts - LaunchdRuntime implementation 276 | 277 | **IPC layer**: 278 | - src/daemon/ipc.ts - Unix socket communication protocol 279 | 280 | ## Core APIs 281 | 282 | ### Orchestrator Interface (src/daemon/runtime.ts) 283 | ```ts 284 | export interface Orchestrator { 285 | ensure( 286 | command: string, 287 | args: string[], 288 | options: EnsureOptions 289 | ): Promise; 290 | stop(id?: string): Promise; 291 | status(): Promise; 292 | clean(): Promise; 293 | } 294 | 295 | export function normalizeCommand( 296 | command: string, 297 | args?: string[] 298 | ): { command: string; args: string[] }; 299 | 300 | export function computeDaemonId( 301 | command: string, 302 | args?: string[], 303 | env?: Record 304 | ): string; 305 | 306 | export function isValidDaemonId(id: string): boolean; 307 | ``` 308 | 309 | ### Daemon Client (src/daemon/client.ts) 310 | ```ts 311 | export class DaemonClient { 312 | constructor(command: string, args: string[], options?: DaemonClientOptions); 313 | async listTools(): Promise; 314 | async callTool(params: { name: string; arguments: any }): Promise; 315 | async ping(): Promise; 316 | } 317 | ``` 318 | 319 | ## Example: End‑to‑End Sequence 320 | 321 | 1) User runs: 322 | ``` 323 | mcpli get-weather -- OPENAI_API_KEY=sk-live node weather-server.js 324 | ``` 325 | 2) CLI parses args and discovers tools: 326 | - parseCommandSpec() extracts env = { OPENAI_API_KEY: "sk-live" }, command = "node", args = ["weather-server.js"]. 327 | - discoverToolsEx(...) creates DaemonClient with env. 328 | 3) DaemonClient computes daemonId = computeDaemonId(command, args, env). 329 | 4) DaemonClient calls orchestrator.ensure(...) which writes plist and ensures launchd service. 330 | 5) Client connects to socket; launchd spawns daemon if needed. 331 | 6) Wrapper creates MCP client with merged env and starts IPC server. 332 | 7) Tool execution flows through IPC → wrapper → MCP server → response. 333 | 8) Later calls with same command/args/env reuse the same daemon for instant startup. 334 | 335 | ## Operational Considerations 336 | 337 | - **Server command requirement**: The server command after -- is always required for all tool execution. 338 | - **Inactivity timeout**: Wrapper resets timer on every IPC request; graceful shutdown on timeout. 339 | - **Logging**: When debug/logs are enabled, the MCP server's stderr is inherited. 340 | - **Cleanup**: Stale plist/sockets are removed when processes are gone via orchestrator.clean(). 341 | - **macOS-only**: Current architecture requires macOS and launchd for process management. 342 | 343 | --- 344 | 345 | ## Conclusion 346 | 347 | The MCPLI architecture represents a sophisticated approach to command-line tool management, combining the benefits of persistent daemon processes with robust process management and efficient IPC communication. The system's design prioritizes performance, reliability, and security while maintaining simplicity for end users. 348 | 349 | Key architectural achievements: 350 | - **Performance**: Sub-100ms tool execution for warm processes (measured 60-63ms for simple tools) 351 | - **Reliability**: Comprehensive error handling with shutdown protection and automatic recovery 352 | - **Scalability**: Efficient resource usage with concurrent client support and daemon isolation 353 | - **Security**: Process isolation and restricted file system permissions 354 | - **Maintainability**: Clean separation of concerns with launchd-based orchestration 355 | 356 | The integration with macOS launchd provides enterprise-grade process management, while the socket activation system ensures efficient resource utilization and fast startup times. The result is a production-ready CLI tool system that transforms simple MCP servers into high-performance, persistent command-line tools. 357 | --------------------------------------------------------------------------------