From dabac8d5b6b7b00a6b727775670fd590a0a7fad5 Mon Sep 17 00:00:00 2001 From: Eli Oshinsky Date: Tue, 17 Mar 2026 21:46:08 -0400 Subject: [PATCH 1/2] fix: migrate distribution to GitHub releases Install platform binaries from GitHub releases so npm and curl installs stop depending on a single host-built artifact. Mirror the lspcli release and update flow to keep mcp-controller portable across supported platforms. --- .github/workflows/ci.yml | 41 ----- .github/workflows/test.yml | 43 +++++ .github/workflows/version.yml | 57 +++++- .gitignore | 9 +- README.md | 68 ++++--- bin/mcp-controller | 56 ++++++ bin/mcp-controller.cmd | 50 ++++++ bun.lock | 26 ++- install.sh | 47 +++++ package.json | 26 ++- script/build.ts | 59 ++++++ script/postinstall.mjs | 142 +++++++++++++++ script/preinstall.mjs | 39 ++++ script/release.ts | 318 +++++++++++++++++++++++++++++++++ src/auto-update.ts | 50 ++++++ src/cli.ts | 140 +++++++++++---- src/commands/update.ts | 49 +++++ src/update-state.ts | 61 +++++++ src/update-types.ts | 11 ++ src/update.ts | 125 +++++++++++++ src/updater-worker.ts | 85 +++++++++ tests/bunx-integration.test.ts | 2 +- tests/cli-args.test.ts | 117 ++++++------ tests/integration.test.ts | 318 ++++++++++++++++++--------------- tests/list-tools.test.ts | 223 ++++++++++++----------- tests/setup.ts | 33 +++- tests/test-utils.ts | 42 +++-- tests/update.test.ts | 203 +++++++++++++++++++++ tests/updater-worker.test.ts | 117 ++++++++++++ tsconfig.json | 11 +- 30 files changed, 2116 insertions(+), 452 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/test.yml create mode 100755 bin/mcp-controller create mode 100644 bin/mcp-controller.cmd create mode 100755 install.sh create mode 100755 script/build.ts create mode 100644 script/postinstall.mjs create mode 100644 script/preinstall.mjs create mode 100755 script/release.ts create mode 100644 src/auto-update.ts create mode 100644 src/commands/update.ts create mode 100644 src/update-state.ts create mode 100644 src/update-types.ts create mode 100644 src/update.ts create mode 100644 src/updater-worker.ts create mode 100644 tests/update.test.ts create mode 100644 tests/updater-worker.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a8e1ed6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Type check - run: bun run typecheck - - - name: Lint - run: bun run lint - - - name: Run tests - run: bun run test - - - name: Build executable - run: bun run build - - - name: Verify executable - run: | - ls -la mcp-controller - # Test that executable runs and shows usage (exits with error code 1 when no args) - ./mcp-controller || [ $? -eq 1 ] \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..06ab43e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run type checking + run: bun run typecheck + + - name: Run lint + run: bun run lint + + - name: Build release binaries + run: bun run build + + - name: Make executables runnable + run: | + chmod +x ./bin/mcp-controller + chmod +x ./dist/mcp-controller-linux-x64/bin/mcp-controller + + - name: Verify CLI wrapper works + run: ./bin/mcp-controller || [ $? -eq 1 ] + + - name: Run tests + run: bun run test diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 5b565a0..f13489c 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -1,11 +1,14 @@ name: Version on: - push: + workflow_run: + workflows: ['Test'] branches: [main] + types: [completed] jobs: version: + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -13,6 +16,8 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 @@ -24,18 +29,52 @@ jobs: - name: Install dependencies run: bun install - - name: Build - run: bun run build - - - name: Test - run: bun run test - - name: Create Release Pull Request or Publish uses: changesets/action@v1 with: - version: bunx changeset version - publish: bunx changeset publish + version: bun run version + publish: bun run release --publish-only title: 'chore: version packages' createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + + - name: Check if release needs binaries + id: check-release + run: | + VERSION="v$(jq -r .version package.json)" + if gh release view "$VERSION" &>/dev/null; then + ASSET_COUNT=$(gh release view "$VERSION" --json assets --jq '.assets | length') + if [ "$ASSET_COUNT" -eq 0 ]; then + echo "Release exists with no binaries, will upload" + echo "needs_binaries=true" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + else + echo "Release already has $ASSET_COUNT assets, skipping" + echo "needs_binaries=false" >> $GITHUB_OUTPUT + fi + else + echo "No release found for $VERSION" + echo "needs_binaries=false" >> $GITHUB_OUTPUT + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload binaries to release + if: steps.check-release.outputs.needs_binaries == 'true' + run: | + cp dist/mcp-controller-linux-x64/bin/mcp-controller mcp-controller-linux-x64 + cp dist/mcp-controller-linux-arm64/bin/mcp-controller mcp-controller-linux-arm64 + cp dist/mcp-controller-darwin-x64/bin/mcp-controller mcp-controller-darwin-x64 + cp dist/mcp-controller-darwin-arm64/bin/mcp-controller mcp-controller-darwin-arm64 + cp dist/mcp-controller-windows-x64/bin/mcp-controller.exe mcp-controller-windows-x64.exe + gh release upload "${{ steps.check-release.outputs.version }}" \ + mcp-controller-linux-x64 \ + mcp-controller-linux-arm64 \ + mcp-controller-darwin-x64 \ + mcp-controller-darwin-arm64 \ + mcp-controller-windows-x64.exe \ + --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 880262d..328f89b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ node_modules/ bun.lockb # Build output -mcp-controller +dist/ # TypeScript *.tsbuildinfo @@ -38,4 +38,9 @@ coverage/ # Temporary files *.tmp -*.temp \ No newline at end of file +*.temp + +# Update state +.mcp-controller-update-state +bin/mcp-controller-downloaded +bin/mcp-controller-downloaded.exe diff --git a/README.md b/README.md index c0f3230..a71eeef 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Model Context Protocol (MCP) server that acts as a proxy between MCP clients a ## Key Features - **๐Ÿ”ง Tool Filtering**: Selectively enable or disable specific tools from target MCP servers -- **๐Ÿ” Transparent Proxying**: Forwards all other MCP protocol messages without modification +- **๐Ÿ” Transparent Proxying**: Forwards all other MCP protocol messages without modification - **โšก Zero Configuration**: Works with any existing MCP server without changes - **๐Ÿ›ก๏ธ Access Control**: Control which tools clients can access for security and usability - **๐Ÿ“ฆ Command Line Interface**: Start any MCP server through command arguments @@ -15,8 +15,13 @@ A Model Context Protocol (MCP) server that acts as a proxy between MCP clients a ## Installation ```bash -bun install -bun run build +curl -fsSL https://raw.githubusercontent.com/eli0shin/mcp-controller/main/install.sh | bash +``` + +Or install via npm: + +```bash +npm install -g mcp-controller ``` ## Usage @@ -25,16 +30,16 @@ bun run build ```bash # Proxy to a local MCP server -./mcp-controller bun run my-server.ts +mcp-controller bun run my-server.ts # Proxy to an npm-distributed MCP server -./mcp-controller @modelcontextprotocol/server-sequential-thinking +mcp-controller @modelcontextprotocol/server-sequential-thinking # Proxy to a Python MCP server -./mcp-controller python -m my_mcp_server +mcp-controller python -m my_mcp_server # Proxy to any executable MCP server -./mcp-controller node server.js --port 3000 +mcp-controller node server.js --port 3000 ``` ### Tool Filtering @@ -43,13 +48,13 @@ Control which tools from the target server are exposed to clients: ```bash # Only allow specific tools (whitelist mode) -./mcp-controller --enabled-tools file-read,file-write,search bun run my-server.ts +mcp-controller --enabled-tools file-read,file-write,search bun run my-server.ts -# Block specific tools (blacklist mode) -./mcp-controller --disabled-tools dangerous-tool,admin-commands python -m my_server +# Block specific tools (blacklist mode) +mcp-controller --disabled-tools dangerous-tool,admin-commands python -m my_server # Multiple tools (comma-separated, no spaces around commas) -./mcp-controller --enabled-tools tool1,tool2,tool3 node server.js +mcp-controller --enabled-tools tool1,tool2,tool3 node server.js ``` ### Filtering Rules @@ -63,30 +68,33 @@ Control which tools from the target server are exposed to clients: ## Use Cases ### Security & Access Control + ```bash # Production environment - only allow safe read-only tools -./mcp-controller --enabled-tools read-file,search,list-files my-server +mcp-controller --enabled-tools read-file,search,list-files my-server # Development environment - block dangerous operations -./mcp-controller --disabled-tools delete-file,format-disk,restart-system my-server +mcp-controller --disabled-tools delete-file,format-disk,restart-system my-server ``` ### Client-Specific Customization + ```bash # For a documentation client - only text processing tools -./mcp-controller --enabled-tools text-search,summarize,translate content-server +mcp-controller --enabled-tools text-search,summarize,translate content-server -# For an admin interface - block user-facing tools -./mcp-controller --disabled-tools user-chat,send-email,post-social admin-server +# For an admin interface - block user-facing tools +mcp-controller --disabled-tools user-chat,send-email,post-social admin-server ``` ### Testing & Development + ```bash # Test specific functionality by isolating tools -./mcp-controller --enabled-tools database-query,cache-get test-server +mcp-controller --enabled-tools database-query,cache-get test-server # Debug by excluding problematic tools -./mcp-controller --disabled-tools flaky-api,slow-process debug-server +mcp-controller --disabled-tools flaky-api,slow-process debug-server ``` ## How it Works @@ -107,7 +115,7 @@ MCP Client โ†” MCP Controller โ†” Target MCP Server ### Message Flow 1. **Client โ†’ Controller โ†’ Target**: All requests forwarded transparently -2. **Target โ†’ Controller โ†’ Client**: +2. **Target โ†’ Controller โ†’ Client**: - `tools/list` responses are filtered based on configuration - All other responses pass through unchanged @@ -115,7 +123,7 @@ MCP Client โ†” MCP Controller โ†” Target MCP Server - โœ… **`tools/list` responses** - Tool arrays are filtered according to your settings - โŒ **Tool calls** - Individual tool invocations pass through (filtered tools simply won't be available) -- โŒ **Resources** - Resource lists and access remain unchanged +- โŒ **Resources** - Resource lists and access remain unchanged - โŒ **Prompts** - Prompt functionality unaffected - โŒ **Other messages** - Initialization, capabilities, etc. pass through @@ -124,6 +132,8 @@ MCP Client โ†” MCP Controller โ†” Target MCP Server ```bash Usage: mcp-controller [--enabled-tools ] [--disabled-tools ] [args...] + mcp-controller update + Options: --enabled-tools Comma-separated list of tools to allow (whitelist mode) --disabled-tools Comma-separated list of tools to block (blacklist mode) @@ -140,15 +150,15 @@ The controller validates arguments at startup and will exit with helpful error m ```bash # Missing command -$ ./mcp-controller --enabled-tools read +$ mcp-controller --enabled-tools read Error: No target command specified # Both filtering modes -$ ./mcp-controller --enabled-tools read --disabled-tools write bun server.ts +$ mcp-controller --enabled-tools read --disabled-tools write bun server.ts Error: --enabled-tools and --disabled-tools are mutually exclusive # Missing tool list -$ ./mcp-controller --enabled-tools bun server.ts +$ mcp-controller --enabled-tools bun server.ts Error: --enabled-tools requires a value ``` @@ -158,15 +168,21 @@ Error: --enabled-tools requires a value # Install dependencies bun install -# Build the executable +# Build release binaries bun run build -# Run in development mode +# Run via wrapper against the local release build +./bin/mcp-controller bun run tests/fixtures/mcp-server.ts + +# Run in development mode bun run dev # Run tests (includes tool filtering tests) bun test +# Update installed binary from GitHub Releases +mcp-controller update + # Lint and format bun run lint bun run format @@ -177,4 +193,4 @@ bun run typecheck ## License -MIT \ No newline at end of file +MIT diff --git a/bin/mcp-controller b/bin/mcp-controller new file mode 100755 index 0000000..bc54422 --- /dev/null +++ b/bin/mcp-controller @@ -0,0 +1,56 @@ +#!/bin/sh +set -e + +if [ -n "$MCP_CONTROLLER_BIN_PATH" ]; then + resolved="$MCP_CONTROLLER_BIN_PATH" +else + script_path="$0" + while [ -L "$script_path" ]; do + link_target="$(readlink "$script_path")" + case "$link_target" in + /*) script_path="$link_target" ;; + *) script_path="$(dirname "$script_path")/$link_target" ;; + esac + done + script_dir="$(dirname "$script_path")" + script_dir="$(cd "$script_dir" && pwd)" + + case "$(uname -s)" in + Darwin) platform="darwin" ;; + Linux) platform="linux" ;; + *) platform="$(uname -s | tr '[:upper:]' '[:lower:]')" ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + aarch64|arm64) arch="arm64" ;; + *) arch="$(uname -m)" ;; + esac + + resolved="" + + candidate="$script_dir/mcp-controller-downloaded" + if [ -f "$candidate" ]; then + resolved="$candidate" + fi + + if [ -z "$resolved" ]; then + name="mcp-controller-${platform}-${arch}" + current_dir="$script_dir" + while [ "$current_dir" != "/" ]; do + candidate="$current_dir/dist/$name/bin/mcp-controller" + if [ -f "$candidate" ]; then + resolved="$candidate" + break + fi + current_dir="$(dirname "$current_dir")" + done + fi + + if [ -z "$resolved" ]; then + printf "Failed to find mcp-controller binary for your platform\n" >&2 + exit 1 + fi +fi + +exec "$resolved" "$@" diff --git a/bin/mcp-controller.cmd b/bin/mcp-controller.cmd new file mode 100644 index 0000000..641f22b --- /dev/null +++ b/bin/mcp-controller.cmd @@ -0,0 +1,50 @@ +@echo off +setlocal enabledelayedexpansion + +if defined MCP_CONTROLLER_BIN_PATH ( + set "resolved=%MCP_CONTROLLER_BIN_PATH%" + goto :execute +) + +set "script_dir=%~dp0" +set "script_dir=%script_dir:~0,-1%" + +set "resolved=" +set "candidate=%script_dir%\mcp-controller-downloaded.exe" +if exist "%candidate%" ( + set "resolved=%candidate%" + goto :execute +) + +if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( + set "arch=x64" +) else if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "arch=arm64" +) else ( + set "arch=x64" +) + +set "name=mcp-controller-windows-!arch!" +set "current_dir=%script_dir%" + +:search_loop +set "candidate=%current_dir%\dist\%name%\bin\mcp-controller.exe" +if exist "%candidate%" ( + set "resolved=%candidate%" + goto :execute +) + +for %%i in ("%current_dir%") do set "parent_dir=%%~dpi" +set "parent_dir=%parent_dir:~0,-1%" + +if "%current_dir%"=="%parent_dir%" goto :not_found +set "current_dir=%parent_dir%" +goto :search_loop + +:not_found +echo Failed to find mcp-controller binary for your platform >&2 +exit /b 1 + +:execute +start /b /wait "" "%resolved%" %* +exit /b %ERRORLEVEL% diff --git a/bun.lock b/bun.lock index 1516221..ba7cc91 100644 --- a/bun.lock +++ b/bun.lock @@ -7,13 +7,17 @@ "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", + "@commander-js/extra-typings": "^14.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "@total-typescript/ts-reset": "^0.6.1", "@types/node": "^22.9.0", + "@types/semver": "^7.7.1", "bun-types": "^1.1.34", + "commander": "^14.0.1", "eslint": "^9.15.0", "eslint-for-ai": "^1.0.8", "prettier": "^3.3.3", + "semver": "^7.7.4", "typescript": "^5.6.3", "zod": "^3.23.8", }, @@ -92,6 +96,8 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], + "@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], "@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], @@ -178,6 +184,8 @@ "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="], @@ -312,6 +320,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="], "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], @@ -780,7 +790,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], @@ -926,6 +936,14 @@ "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@changesets/apply-release-plan/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@changesets/assemble-release-plan/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@changesets/cli/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@changesets/get-dependents-graph/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@changesets/parse/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -950,12 +968,16 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="], "eslint-plugin-import-x/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "eslint-plugin-import-x/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -966,6 +988,8 @@ "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "raw-body/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..53b473e --- /dev/null +++ b/install.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +REPO="eli0shin/mcp-controller" +INSTALL_DIR="${HOME}/.local/bin" +BINARY_NAME="mcp-controller" + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +ARTIFACT="${BINARY_NAME}-${OS}-${ARCH}" + +echo "Detected: ${OS}-${ARCH}" +echo "Installing to: ${INSTALL_DIR}/${BINARY_NAME}" + +mkdir -p "$INSTALL_DIR" + +DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ARTIFACT}" +echo "Downloading from: ${DOWNLOAD_URL}" + +curl -fsSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/${BINARY_NAME}" +chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + +echo "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" + +if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then + echo "" + echo "Add this to your shell profile to use ${BINARY_NAME}:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi diff --git a/package.json b/package.json index 5ce7477..1c09792 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,23 @@ "version": "0.4.0", "description": "MCP server proxy that enables controlling availability of tools.", "type": "module", + "main": "src/cli.ts", "bin": { - "mcp-controller": "mcp-controller" + "mcp-controller": "./bin/mcp-controller" }, "files": [ - "README.md", - "package.json", - "bun.lock", - "mcp-controller" + "bin/", + "script/preinstall.mjs", + "script/postinstall.mjs", + "README.md" ], + "bugs": { + "url": "https://github.com/eli0shin/mcp-controller/issues" + }, + "homepage": "https://github.com/eli0shin/mcp-controller#readme", "scripts": { - "build": "bun build src/cli.ts --compile --outfile mcp-controller", + "build": "./script/build.ts", + "release": "./script/release.ts", "dev": "bun run src/cli.ts", "typecheck": "bun tsc --noEmit", "lint": "eslint .", @@ -22,18 +28,24 @@ "format:fix": "prettier --write .", "test": "bun test", "test:watch": "bun test tests/ --watch", - "prepublish": "bun test && bun run build" + "version": "bunx changeset version", + "preinstall": "node ./script/preinstall.mjs", + "postinstall": "node ./script/postinstall.mjs" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", + "@commander-js/extra-typings": "^14.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "@total-typescript/ts-reset": "^0.6.1", "@types/node": "^22.9.0", + "@types/semver": "^7.7.1", "bun-types": "^1.1.34", + "commander": "^14.0.1", "eslint": "^9.15.0", "eslint-for-ai": "^1.0.8", "prettier": "^3.3.3", + "semver": "^7.7.4", "typescript": "^5.6.3", "zod": "^3.23.8" }, diff --git a/script/build.ts b/script/build.ts new file mode 100755 index 0000000..52476ce --- /dev/null +++ b/script/build.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun + +import { $ } from 'bun'; +import pkg from '../package.json'; + +const targets = [ + ['windows', 'x64'], + ['linux', 'arm64'], + ['linux', 'x64'], + ['darwin', 'x64'], + ['darwin', 'arm64'], +] as const; + +async function buildTarget( + os: string, + arch: string, + version: string +): Promise<[string, string]> { + process.stdout.write(`Building ${os}-${arch}\n`); + const name = `${pkg.name}-${os}-${arch}`; + await $`mkdir -p dist/${name}/bin`; + + const binaryName = `mcp-controller${os === 'windows' ? '.exe' : ''}`; + const outfile = `dist/${name}/bin/${binaryName}`; + const target = `bun-${os}-${arch}`; + + await $`bun build src/cli.ts --compile --target ${target} --outfile ${outfile}`; + + await Bun.file(`dist/${name}/package.json`).write( + JSON.stringify( + { + name, + version, + os: [os === 'windows' ? 'win32' : os], + cpu: [arch], + repository: pkg.repository, + }, + null, + 2 + ) + '\n' + ); + + return [name, version]; +} + +export async function build(version: string): Promise> { + await $`rm -rf dist`; + + const entries = []; + for (const [os, arch] of targets) { + entries.push(await buildTarget(os, arch, version)); + } + + return Object.fromEntries(entries); +} + +if (import.meta.main) { + await build(pkg.version); +} diff --git a/script/postinstall.mjs b/script/postinstall.mjs new file mode 100644 index 0000000..1539d79 --- /dev/null +++ b/script/postinstall.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const REPO = 'eli0shin/mcp-controller'; + +function shouldSkipPostinstall() { + const packageRoot = path.join(__dirname, '..'); + const srcDir = path.join(packageRoot, 'src'); + if (fs.existsSync(srcDir)) { + console.log('Skipping postinstall (running from source)'); + return true; + } + return false; +} + +function detectPlatformAndArch() { + let platform; + switch (os.platform()) { + case 'darwin': + platform = 'darwin'; + break; + case 'linux': + platform = 'linux'; + break; + case 'win32': + platform = 'windows'; + break; + default: + platform = os.platform(); + } + + let arch; + switch (os.arch()) { + case 'x64': + arch = 'x64'; + break; + case 'arm64': + arch = 'arm64'; + break; + default: + arch = os.arch(); + } + + return { platform, arch }; +} + +function getDownloadedBinaryPath() { + const { platform } = detectPlatformAndArch(); + const binary = + platform === 'windows' + ? 'mcp-controller-downloaded.exe' + : 'mcp-controller-downloaded'; + return path.join(__dirname, '..', 'bin', binary); +} + +async function downloadLatestBinary() { + const { platform, arch } = detectPlatformAndArch(); + const asset = + platform === 'windows' + ? `mcp-controller-${platform}-${arch}.exe` + : `mcp-controller-${platform}-${arch}`; + const url = `https://github.com/${REPO}/releases/latest/download/${asset}`; + const response = await fetch(url, { + headers: { + 'User-Agent': 'mcp-controller', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status}`); + } + + const targetPath = getDownloadedBinaryPath(); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); + + if (platform !== 'windows') { + fs.chmodSync(targetPath, 0o755); + } + + console.log(`Downloaded binary: ${targetPath}`); +} + +async function regenerateWindowsCmdWrappers() { + console.log('Windows + npm: Rebuilding bin links'); + + try { + const { execSync } = require('child_process'); + const pkgPath = path.join(__dirname, '..'); + + const isGlobal = + process.env.npm_config_global === 'true' || + pkgPath.includes(path.join('npm', 'node_modules')); + + const cmd = `npm rebuild mcp-controller --ignore-scripts${isGlobal ? ' -g' : ''}`; + const opts = { + stdio: 'inherit', + shell: true, + ...(isGlobal ? {} : { cwd: path.join(pkgPath, '..', '..') }), + }; + + execSync(cmd, opts); + console.log('Successfully rebuilt npm bin links'); + } catch (error) { + console.error('Error rebuilding npm links:', error.message); + } +} + +async function main() { + if (shouldSkipPostinstall()) { + return; + } + + try { + await downloadLatestBinary(); + + if ( + os.platform() === 'win32' && + process.env.npm_config_user_agent?.startsWith('npm') + ) { + await regenerateWindowsCmdWrappers(); + } + } catch (error) { + console.error('Failed to install release binary:', error.message); + process.exit(1); + } +} + +try { + await main(); +} catch (error) { + console.error('Postinstall error:', error.message); + process.exit(0); +} diff --git a/script/preinstall.mjs b/script/preinstall.mjs new file mode 100644 index 0000000..463dee0 --- /dev/null +++ b/script/preinstall.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function main() { + if (os.platform() !== 'win32') { + console.log('Non-Windows platform, skipping preinstall'); + return; + } + + console.log('Windows: Modifying package.json bin entry'); + + const packageJsonPath = path.join(__dirname, '..', 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + packageJson.bin = { + 'mcp-controller': './bin/mcp-controller.cmd', + }; + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + console.log('Updated package.json bin to use mcp-controller.cmd'); + + const unixScript = path.join(__dirname, '..', 'bin', 'mcp-controller'); + if (fs.existsSync(unixScript)) { + fs.unlinkSync(unixScript); + } +} + +try { + main(); +} catch (error) { + console.error('Preinstall error:', error.message); + process.exit(0); +} diff --git a/script/release.ts b/script/release.ts new file mode 100755 index 0000000..fa48d4d --- /dev/null +++ b/script/release.ts @@ -0,0 +1,318 @@ +#!/usr/bin/env bun +/* eslint-disable no-console */ + +import { Command } from '@commander-js/extra-typings'; +import { $ } from 'bun'; +import pkg from '../package.json'; +import { build } from './build.ts'; + +type BumpType = 'patch' | 'minor' | 'major'; + +function isBumpType(value: string): value is BumpType { + return value === 'patch' || value === 'minor' || value === 'major'; +} + +const program = new Command() + .name('release') + .description('Release a new version of mcp-controller') + .version(pkg.version) + .argument( + '[bump]', + 'version bump type (patch, minor, major)', + (value: string) => { + if (value && !isBumpType(value)) { + throw new Error( + `Invalid bump type: ${value}. Must be patch, minor, or major.` + ); + } + return value; + } + ) + .option('--skip-tests', 'skip running tests') + .option('--dry-run', 'perform a dry run without publishing') + .option( + '--publish-only', + 'skip version bumping and git operations, just build and publish' + ) + .parse(); + +const bumpType = program.args[0]; +const options = program.opts(); + +function bumpVersion(current: string, type: BumpType): string { + const [major, minor, patch] = current.split('.').map(Number); + + switch (type) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + } +} + +async function determineVersion(): Promise { + const currentVersion = pkg.version; + console.log(`Current version: ${currentVersion}`); + + if (bumpType && isBumpType(bumpType)) { + const newVersion = bumpVersion(currentVersion, bumpType); + console.log(`๐Ÿ“ฆ Bumping ${bumpType}: ${currentVersion} โ†’ ${newVersion}`); + return newVersion; + } + + console.log('\nSelect version bump:'); + console.log( + ` 1) patch: ${currentVersion} โ†’ ${bumpVersion(currentVersion, 'patch')}` + ); + console.log( + ` 2) minor: ${currentVersion} โ†’ ${bumpVersion(currentVersion, 'minor')}` + ); + console.log( + ` 3) major: ${currentVersion} โ†’ ${bumpVersion(currentVersion, 'major')}` + ); + console.log(' 4) custom'); + + const choice = prompt('Enter choice (1-4):'); + + if (!choice) { + console.error('โŒ No choice entered'); + process.exit(1); + } + + switch (choice) { + case '1': + return bumpVersion(currentVersion, 'patch'); + case '2': + return bumpVersion(currentVersion, 'minor'); + case '3': + return bumpVersion(currentVersion, 'major'); + case '4': { + const custom = prompt('Enter version (X.Y.Z):'); + if (!custom || !/^\d+\.\d+\.\d+$/.test(custom)) { + console.error('โŒ Invalid version format'); + process.exit(1); + } + return custom; + } + default: + console.error('โŒ Invalid choice'); + process.exit(1); + } +} + +async function validateGitStatus() { + console.log('\n๐Ÿ” Checking git status...'); + + const branch = await $`git rev-parse --abbrev-ref HEAD`.text(); + if (branch.trim() !== 'main') { + console.error( + `โŒ Error: Must be on main branch (currently on ${branch.trim()})` + ); + process.exit(1); + } + + const status = await $`git status --porcelain`.text(); + if (status.trim()) { + console.error( + 'โŒ Error: Working directory is not clean. Commit or stash changes first:' + ); + console.error(status); + process.exit(1); + } + + console.log('โœ… Git status is clean'); +} + +async function validateNpmAuth() { + console.log('\n๐Ÿ”‘ Checking npm authentication...'); + + try { + await $`npm whoami`.quiet(); + console.log('โœ… Authenticated with npm'); + } catch { + console.error('โŒ Error: Not authenticated with npm. Run: npm login'); + process.exit(1); + } +} + +async function runTests() { + if (options.skipTests) { + console.log('\nโš ๏ธ Skipping tests (--skip-tests)'); + return; + } + + console.log('\n๐Ÿงช Running tests...'); + + try { + await $`bun test`; + console.log('โœ… All tests passed'); + } catch { + console.error('โŒ Error: Tests failed. Fix tests before publishing.'); + process.exit(1); + } +} + +async function buildBinaries(version: string): Promise> { + console.log('\n๐Ÿ”จ Building platform binaries...'); + + try { + const binaries = await build(version); + console.log('โœ… Built all platform binaries'); + return binaries; + } catch { + console.error('โŒ Error: Build failed'); + process.exit(1); + } +} + +async function publishMainPackage(version: string) { + console.log('\n๐Ÿ“ค Publishing main package...'); + + await $`mkdir -p ./dist/${pkg.name}`; + await $`cp -r ./bin ./dist/${pkg.name}/bin`; + await $`cp ./script/preinstall.mjs ./dist/${pkg.name}/preinstall.mjs`; + await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`; + await $`cp ./README.md ./dist/${pkg.name}/README.md`; + + await Bun.file(`./dist/${pkg.name}/package.json`).write( + JSON.stringify( + { + name: pkg.name, + version, + description: pkg.description, + repository: pkg.repository, + bugs: pkg.bugs, + homepage: pkg.homepage, + license: pkg.license, + bin: { + [pkg.name]: `./bin/${pkg.name}`, + }, + scripts: { + preinstall: 'node ./preinstall.mjs', + postinstall: 'node ./postinstall.mjs', + }, + }, + null, + 2 + ) + '\n' + ); + + try { + if (options.dryRun) { + console.log(`[DRY RUN] Would publish ${pkg.name}@${version}`); + } else { + await $`cd ./dist/${pkg.name} && npm publish --access public`; + console.log(`โœ… Published ${pkg.name}@${version}`); + } + } catch (error) { + console.error('โŒ Failed to publish main package'); + throw error; + } +} + +async function updatePackageVersion(version: string) { + console.log('\n๐Ÿ“ Updating package.json version...'); + + const packageJson = { ...pkg, version }; + await Bun.file('package.json').write( + JSON.stringify(packageJson, null, 2) + '\n' + ); + + if (options.dryRun) { + console.log(`[DRY RUN] Would commit and push version ${version}`); + } else { + await $`git add package.json`; + await $`git commit -m ${version}`; + await $`git push`; + console.log(`โœ… Updated package.json to ${version} and committed`); + } +} + +async function createGitTag(version: string) { + console.log('\n๐Ÿท๏ธ Creating git tag...'); + + try { + if (options.dryRun) { + console.log(`[DRY RUN] Would create and push tag v${version}`); + } else { + await $`git tag v${version}`; + await $`git push origin v${version}`; + console.log(`โœ… Created and pushed tag v${version}`); + } + } catch (error) { + console.error('โŒ Failed to create git tag'); + throw error; + } +} + +async function main() { + console.log('๐Ÿš€ Starting release process...\n'); + + if (options.dryRun) { + console.log('๐Ÿ” DRY RUN MODE - No changes will be published\n'); + } + + if (options.publishOnly) { + const version = pkg.version; + console.log(`๐Ÿ“ฆ Publishing version: ${version}`); + + await buildBinaries(version); + await publishMainPackage(version); + + if (!options.dryRun) { + await $`bunx changeset tag`; + } + + if (options.dryRun) { + console.log(`\nโœ… Dry run completed for ${pkg.name}@${version}`); + } else { + console.log(`\nโœจ Successfully published ${pkg.name}@${version}!`); + console.log( + `\n๐Ÿ“ฆ View on npm: https://www.npmjs.com/package/${pkg.name}` + ); + } + return; + } + + const version = await determineVersion(); + + console.log(`\n๐Ÿ“ฆ Releasing version: ${version}`); + if (options.dryRun) { + console.log('โš ๏ธ Dry run - skipping confirmation'); + } else { + const confirm = prompt('\nProceed with release? (yes/no):'); + if (confirm?.toLowerCase() !== 'yes') { + console.log('โŒ Release cancelled'); + process.exit(0); + } + } + + await validateGitStatus(); + if (!options.dryRun) { + await validateNpmAuth(); + } + await runTests(); + await buildBinaries(version); + await publishMainPackage(version); + await updatePackageVersion(version); + await createGitTag(version); + + if (options.dryRun) { + console.log(`\nโœ… Dry run completed for ${pkg.name}@${version}`); + } else { + console.log(`\nโœจ Successfully released ${pkg.name}@${version}!`); + console.log(`\n๐Ÿ“ฆ View on npm: https://www.npmjs.com/package/${pkg.name}`); + console.log( + `๐Ÿท๏ธ View tag: https://github.com/${pkg.repository.url.match(/github\.com\/(.+)/)?.[1]}/releases/tag/v${version}` + ); + } +} + +try { + await main(); +} catch (error) { + console.error('\n๐Ÿ’ฅ Release failed:', error); + process.exit(1); +} diff --git a/src/auto-update.ts b/src/auto-update.ts new file mode 100644 index 0000000..0fe1270 --- /dev/null +++ b/src/auto-update.ts @@ -0,0 +1,50 @@ +import { spawn as nodeSpawn } from 'node:child_process'; +import { + readUpdateState, + shouldCheckForUpdate, + getUpdateStatePath, +} from './update-state.js'; +import type { UpdateBehavior } from './update-types.js'; + +export type SpawnFn = (args: string[]) => void; + +function defaultSpawn(args: string[]): void { + const [cmd, ...rest] = args; + const proc = nodeSpawn(cmd, rest, { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + proc.unref(); +} + +export async function handleAutoUpdate( + currentVersion: string, + updateBehavior: UpdateBehavior, + checkIntervalHours = 24, + statePath: string = getUpdateStatePath(), + spawnFn: SpawnFn = defaultSpawn +): Promise { + if (updateBehavior === 'off') { + return; + } + + const stateResult = await readUpdateState(statePath); + const state = stateResult.success ? stateResult.data : null; + + if (!shouldCheckForUpdate(state, checkIntervalHours)) { + return; + } + + const binaryPath = process.execPath; + spawnFn([ + binaryPath, + '--update-worker', + currentVersion, + binaryPath, + updateBehavior, + ]); +} + +export function getUpdateBehavior(): UpdateBehavior { + return process.env.MCP_CONTROLLER_AUTO_UPDATE === 'off' ? 'off' : 'auto'; +} diff --git a/src/cli.ts b/src/cli.ts index fcc16f2..1bbd207 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,16 @@ #!/usr/bin/env bun +import packageJson from '../package.json' with { type: 'json' }; import { McpProxyServer } from './proxy-server.js'; -import { parseJsonRpcResponse, parseToolsArray, type ProxyConfig } from './types.js'; +import { getUpdateBehavior, handleAutoUpdate } from './auto-update.js'; +import { runUpdateCommand } from './commands/update.js'; +import { + parseJsonRpcResponse, + parseToolsArray, + type ProxyConfig, +} from './types.js'; import { TargetServerManager } from './target-server.js'; +import { runUpdaterWorker } from './updater-worker.js'; import { matchesToolPattern } from './utils.js'; function parseListToolsArguments(args: string[]): ProxyConfig { @@ -24,10 +32,15 @@ function parseListToolsArguments(args: string[]): ProxyConfig { process.exit(1); } if (disabledTools !== undefined) { - process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.stderr.write( + 'Error: --enabled-tools and --disabled-tools are mutually exclusive\n' + ); process.exit(1); } - enabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + enabledTools = args[i + 1] + .split(',') + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); i++; // Skip the value argument } else if (arg === '--disabled-tools') { if (i + 1 >= args.length) { @@ -35,10 +48,15 @@ function parseListToolsArguments(args: string[]): ProxyConfig { process.exit(1); } if (enabledTools !== undefined) { - process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.stderr.write( + 'Error: --enabled-tools and --disabled-tools are mutually exclusive\n' + ); process.exit(1); } - disabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + disabledTools = args[i + 1] + .split(',') + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); i++; // Skip the value argument } else { targetCommand.push(arg); @@ -62,15 +80,31 @@ function parseListToolsArguments(args: string[]): ProxyConfig { function parseArguments(): ProxyConfig { const args = process.argv.slice(2); - + if (args.length === 0) { - process.stderr.write('Usage: mcp-controller [--enabled-tools ] [--disabled-tools ] [args...]\n'); - process.stderr.write(' mcp-controller list-tools [--enabled-tools ] [--disabled-tools ] [args...]\n'); - process.stderr.write('Tool patterns support wildcards: use * to match any characters (e.g., get_* matches get_logs, get_metrics)\n'); - process.stderr.write('Example: mcp-controller --enabled-tools add,subtract bun run server.ts\n'); - process.stderr.write('Example: mcp-controller --enabled-tools "get_*,list_*" bun run server.ts\n'); - process.stderr.write('Example: mcp-controller list-tools bun run server.ts\n'); - process.stderr.write('Example: mcp-controller --disabled-tools dangerous-tool bun run server.ts\n'); + process.stderr.write( + 'Usage: mcp-controller [--enabled-tools ] [--disabled-tools ] [args...]\n' + ); + process.stderr.write( + ' mcp-controller list-tools [--enabled-tools ] [--disabled-tools ] [args...]\n' + ); + process.stderr.write(' mcp-controller update\n'); + process.stderr.write( + 'Tool patterns support wildcards: use * to match any characters (e.g., get_* matches get_logs, get_metrics)\n' + ); + process.stderr.write( + 'Example: mcp-controller --enabled-tools add,subtract bun run server.ts\n' + ); + process.stderr.write( + 'Example: mcp-controller --enabled-tools "get_*,list_*" bun run server.ts\n' + ); + process.stderr.write( + 'Example: mcp-controller list-tools bun run server.ts\n' + ); + process.stderr.write( + 'Example: mcp-controller --disabled-tools dangerous-tool bun run server.ts\n' + ); + process.stderr.write('Example: mcp-controller update\n'); process.exit(1); } @@ -92,10 +126,15 @@ function parseArguments(): ProxyConfig { process.exit(1); } if (disabledTools !== undefined) { - process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.stderr.write( + 'Error: --enabled-tools and --disabled-tools are mutually exclusive\n' + ); process.exit(1); } - enabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + enabledTools = args[i + 1] + .split(',') + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); i++; // Skip the value argument } else if (arg === '--disabled-tools') { if (i + 1 >= args.length) { @@ -103,10 +142,15 @@ function parseArguments(): ProxyConfig { process.exit(1); } if (enabledTools !== undefined) { - process.stderr.write('Error: --enabled-tools and --disabled-tools are mutually exclusive\n'); + process.stderr.write( + 'Error: --enabled-tools and --disabled-tools are mutually exclusive\n' + ); process.exit(1); } - disabledTools = args[i + 1].split(',').map(tool => tool.trim()).filter(tool => tool.length > 0); + disabledTools = args[i + 1] + .split(',') + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); i++; // Skip the value argument } else { targetCommand.push(arg); @@ -135,7 +179,7 @@ async function listTools(config: ProxyConfig): Promise { try { // Start the target server targetServer = await targetManager.startTargetServer(config); - + // Send initialize request const initializeRequest = { jsonrpc: '2.0', @@ -156,16 +200,16 @@ async function listTools(config: ProxyConfig): Promise { // Wait for initialize response const reader = targetServer.stdout.getReader(); let buffer = ''; - + // Read initialize response const { value: initValue } = await reader.read(); if (!initValue) throw new Error('No response from server'); - + buffer += new TextDecoder().decode(initValue); const initLines = buffer.split('\n'); - const initResponse = initLines.find(line => line.trim()); + const initResponse = initLines.find((line) => line.trim()); if (!initResponse) throw new Error('No valid response received'); - + const parsedInitResponse = parseJsonRpcResponse(JSON.parse(initResponse)); if (!parsedInitResponse) { throw new Error('Invalid JSON-RPC response from server'); @@ -189,7 +233,7 @@ async function listTools(config: ProxyConfig): Promise { while (true) { const { value, done } = await reader.read(); if (done) break; - + toolsBuffer += new TextDecoder().decode(value); const lines = toolsBuffer.split('\n'); @@ -210,14 +254,25 @@ async function listTools(config: ProxyConfig): Promise { const disabledTools = config.disabledTools; if (enabledTools) { - tools = tools.filter((tool) => enabledTools.some(pattern => matchesToolPattern(tool.name, pattern))); + tools = tools.filter((tool) => + enabledTools.some((pattern) => + matchesToolPattern(tool.name, pattern) + ) + ); } else if (disabledTools) { - tools = tools.filter((tool) => !disabledTools.some(pattern => matchesToolPattern(tool.name, pattern))); + tools = tools.filter( + (tool) => + !disabledTools.some((pattern) => + matchesToolPattern(tool.name, pattern) + ) + ); } // Print tools in the requested format for (const tool of tools) { - process.stdout.write(`${tool.name}: ${tool.description ?? 'No description available'}\n`); + process.stdout.write( + `${tool.name}: ${tool.description ?? 'No description available'}\n` + ); } return; // Exit successfully @@ -229,11 +284,12 @@ async function listTools(config: ProxyConfig): Promise { } } } - + throw new Error('No tools/list response received'); - } catch (error) { - process.stderr.write(`Error listing tools: ${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write( + `Error listing tools: ${error instanceof Error ? error.message : String(error)}\n` + ); process.exit(1); } finally { await targetManager.stopTargetServer(); @@ -242,15 +298,29 @@ async function listTools(config: ProxyConfig): Promise { async function main(): Promise { try { + if (process.argv[2] === '--update-worker') { + await runUpdaterWorker(); + process.exit(0); + } + + await handleAutoUpdate(packageJson.version, getUpdateBehavior()).catch( + () => {} + ); + + if (process.argv[2] === 'update') { + await runUpdateCommand(); + return; + } + const config = parseArguments(); - + if (config.mode === 'list-tools') { await listTools(config); return; } - + const proxyServer = new McpProxyServer(config); - + // Handle graceful shutdown process.on('SIGINT', async () => { process.stderr.write('\nShutting down controller...\n'); @@ -266,7 +336,9 @@ async function main(): Promise { await proxyServer.start(); } catch (error) { - process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write( + `Error: ${error instanceof Error ? error.message : String(error)}\n` + ); process.exit(1); } } @@ -274,4 +346,4 @@ async function main(): Promise { // Only run if this is the main module if (import.meta.main) { void main(); -} \ No newline at end of file +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..cc004ad --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,49 @@ +import { dirname } from 'node:path'; +import packageJson from '../../package.json' with { type: 'json' }; +import { + fetchLatestVersion, + isNewerVersion, + downloadBinary, + replaceBinary, +} from '../update.js'; + +export async function runUpdateCommand(): Promise { + process.stdout.write(`Current version: ${packageJson.version}\n`); + process.stdout.write('Checking for updates...\n'); + + const releaseResult = await fetchLatestVersion(); + if (!releaseResult.success) { + process.stderr.write( + `Error checking for updates: ${releaseResult.error}\n` + ); + process.exit(1); + } + + const { version: latestVersion, downloadUrl } = releaseResult.data; + + if (!isNewerVersion(packageJson.version, latestVersion)) { + process.stdout.write( + `Already on latest version (v${packageJson.version})\n` + ); + return; + } + + process.stdout.write(`Updating to v${latestVersion}...\n`); + + const binaryPath = process.execPath; + const binaryDir = dirname(binaryPath); + + const downloadResult = await downloadBinary(downloadUrl, binaryDir); + if (!downloadResult.success) { + process.stderr.write(`Error downloading update: ${downloadResult.error}\n`); + process.exit(1); + } + + const replaceResult = await replaceBinary(downloadResult.data, binaryPath); + if (!replaceResult.success) { + process.stderr.write(`Error installing update: ${replaceResult.error}\n`); + process.exit(1); + } + + process.stdout.write(`Updated to v${latestVersion}\n`); +} diff --git a/src/update-state.ts b/src/update-state.ts new file mode 100644 index 0000000..21282af --- /dev/null +++ b/src/update-state.ts @@ -0,0 +1,61 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { OperationResult, UpdateState } from './update-types.js'; + +export function getUpdateStatePath(): string { + const xdgStateHome = process.env.XDG_STATE_HOME; + if (xdgStateHome) { + return join(xdgStateHome, 'mcp-controller-update-state'); + } + return join(homedir(), '.mcp-controller-update-state'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isUpdateState(value: unknown): value is UpdateState { + if (!isRecord(value)) return false; + return typeof value.lastCheckedAt === 'number'; +} + +export async function readUpdateState( + statePath: string = getUpdateStatePath() +): Promise> { + const file = Bun.file(statePath); + + if (!(await file.exists())) { + return { success: true, data: null }; + } + + try { + const content: unknown = await file.json(); + if (!isUpdateState(content)) { + return { success: true, data: null }; + } + return { success: true, data: content }; + } catch { + return { success: true, data: null }; + } +} + +export async function writeUpdateState( + statePath: string, + state: UpdateState +): Promise { + try { + await Bun.write(statePath, JSON.stringify(state, null, 2) + '\n'); + return { success: true, data: undefined }; + } catch { + return { success: false, error: 'Failed to write update state' }; + } +} + +export function shouldCheckForUpdate( + state: UpdateState | null, + intervalHours = 24 +): boolean { + if (!state) return true; + const cooldownMs = intervalHours * 60 * 60 * 1000; + return Date.now() - state.lastCheckedAt >= cooldownMs; +} diff --git a/src/update-types.ts b/src/update-types.ts new file mode 100644 index 0000000..90a6db7 --- /dev/null +++ b/src/update-types.ts @@ -0,0 +1,11 @@ +export type Platform = 'darwin' | 'linux' | 'windows'; +export type Architecture = 'x64' | 'arm64'; +export type UpdateBehavior = 'auto' | 'off'; + +export type UpdateState = { + lastCheckedAt: number; +}; + +export type OperationResult = + | { success: true; data: T } + | { success: false; error: string }; diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..ed43d7e --- /dev/null +++ b/src/update.ts @@ -0,0 +1,125 @@ +import { platform, arch } from 'node:os'; +import { join } from 'node:path'; +import { chmod, rename, unlink } from 'node:fs/promises'; +import { valid, gt, prerelease } from 'semver'; +import type { + OperationResult, + Platform, + Architecture, +} from './update-types.js'; + +const GITHUB_REPO = 'eli0shin/mcp-controller'; + +type GitHubRelease = { + tag_name: string; +}; + +function isGitHubRelease(data: unknown): data is GitHubRelease { + if (typeof data !== 'object' || data === null) return false; + if (!('tag_name' in data)) return false; + return typeof data.tag_name === 'string'; +} + +export function getBinaryName(p: Platform, a: Architecture): string { + return p === 'windows' + ? `mcp-controller-${p}-${a}.exe` + : `mcp-controller-${p}-${a}`; +} + +export function isPrerelease(version: string): boolean { + return prerelease(version) !== null; +} + +export function isNewerVersion(current: string, latest: string): boolean { + if (!valid(current) || !valid(latest)) return false; + return gt(latest, current); +} + +export function getPlatform(): OperationResult { + const p = platform(); + if (p === 'darwin') return { success: true, data: 'darwin' }; + if (p === 'linux') return { success: true, data: 'linux' }; + if (p === 'win32') return { success: true, data: 'windows' }; + return { success: false, error: `Unsupported platform: ${p}` }; +} + +export function getArchitecture(): OperationResult { + const a = arch(); + if (a === 'x64') return { success: true, data: 'x64' }; + if (a === 'arm64') return { success: true, data: 'arm64' }; + return { success: false, error: `Unsupported architecture: ${a}` }; +} + +export async function fetchLatestVersion(): Promise< + OperationResult<{ version: string; downloadUrl: string }> +> { + const platformResult = getPlatform(); + if (!platformResult.success) return platformResult; + + const archResult = getArchitecture(); + if (!archResult.success) return archResult; + + const binaryName = getBinaryName(platformResult.data, archResult.data); + const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; + + const response = await fetch(apiUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'mcp-controller', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { success: false, error: 'No releases found' }; + } + return { success: false, error: `GitHub API error: ${response.status}` }; + } + + const data: unknown = await response.json(); + if (!isGitHubRelease(data)) { + return { success: false, error: 'Invalid response from GitHub API' }; + } + const version = data.tag_name.replace(/^v/, ''); + const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/latest/download/${binaryName}`; + + return { success: true, data: { version, downloadUrl } }; +} + +export async function downloadBinary( + url: string, + targetDir: string +): Promise> { + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + return { success: false, error: 'Binary not found for this platform' }; + } + return { success: false, error: `Download failed: ${response.status}` }; + } + + const tempPath = join(targetDir, `.mcp-controller-update-${Date.now()}`); + const arrayBuffer = await response.arrayBuffer(); + await Bun.write(tempPath, arrayBuffer); + await chmod(tempPath, 0o755); + + return { success: true, data: tempPath }; +} + +export async function replaceBinary( + tempPath: string, + targetPath: string +): Promise { + try { + await rename(tempPath, targetPath); + return { success: true, data: undefined }; + } catch (err) { + try { + await unlink(tempPath); + } catch {} + + const message = err instanceof Error ? err.message : 'Unknown error'; + return { success: false, error: `Failed to replace binary: ${message}` }; + } +} diff --git a/src/updater-worker.ts b/src/updater-worker.ts new file mode 100644 index 0000000..429e29d --- /dev/null +++ b/src/updater-worker.ts @@ -0,0 +1,85 @@ +import { dirname } from 'node:path'; +import { + fetchLatestVersion, + isNewerVersion, + isPrerelease, + downloadBinary, + replaceBinary, +} from './update.js'; +import { getUpdateStatePath, writeUpdateState } from './update-state.js'; +import type { UpdateState } from './update-types.js'; + +export type WorkerDeps = { + fetchLatestVersion: typeof fetchLatestVersion; + downloadBinary: typeof downloadBinary; + replaceBinary: typeof replaceBinary; + writeUpdateState: typeof writeUpdateState; + getUpdateStatePath: typeof getUpdateStatePath; +}; + +const defaultDeps: WorkerDeps = { + fetchLatestVersion, + downloadBinary, + replaceBinary, + writeUpdateState, + getUpdateStatePath, +}; + +export async function runUpdaterWorker( + deps: WorkerDeps = defaultDeps +): Promise { + const [currentVersion, binaryPath] = process.argv.slice(3); + + if (!currentVersion || !binaryPath) { + return; + } + + const statePath = deps.getUpdateStatePath(); + + try { + const releaseResult = await deps.fetchLatestVersion(); + if (!releaseResult.success) { + return; + } + + const { version: latestVersion, downloadUrl } = releaseResult.data; + + if (isPrerelease(latestVersion)) { + await updateTimestamp(statePath, deps); + return; + } + + if (!isNewerVersion(currentVersion, latestVersion)) { + await updateTimestamp(statePath, deps); + return; + } + + const binaryDir = dirname(binaryPath); + const downloadResult = await deps.downloadBinary(downloadUrl, binaryDir); + if (!downloadResult.success) { + await updateTimestamp(statePath, deps); + return; + } + + const replaceResult = await deps.replaceBinary( + downloadResult.data, + binaryPath + ); + if (!replaceResult.success) { + await updateTimestamp(statePath, deps); + return; + } + + await updateTimestamp(statePath, deps); + } catch {} +} + +async function updateTimestamp( + statePath: string, + deps: WorkerDeps +): Promise { + const state = { + lastCheckedAt: Date.now(), + } satisfies UpdateState; + await deps.writeUpdateState(statePath, state); +} diff --git a/tests/bunx-integration.test.ts b/tests/bunx-integration.test.ts index eac4cc8..266976a 100644 --- a/tests/bunx-integration.test.ts +++ b/tests/bunx-integration.test.ts @@ -11,7 +11,7 @@ const InitializeResponseSchema = JSONRPCResponseSchema.extend({ result: InitializeResultSchema, }); -const controllerExecutable = path.resolve('./mcp-controller'); +const controllerExecutable = path.resolve('./bin/mcp-controller'); describe('Bunx Integration Tests', () => { test('should work with npm package @modelcontextprotocol/server-sequential-thinking', async () => { diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index f96cc98..fc4eb6e 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -2,104 +2,104 @@ import { test, expect, describe } from 'bun:test'; import path from 'path'; describe('CLI Argument Validation Tests', () => { - const controllerExecutable = path.resolve('./mcp-controller'); + const controllerExecutable = path.resolve('./bin/mcp-controller'); const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); test('should reject when both --enabled-tools and --disabled-tools are provided', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--enabled-tools', 'add', - '--disabled-tools', 'get-args', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + '--enabled-tools', + 'add', + '--disabled-tools', + 'get-args', + 'bun', + 'run', + fixtureServerPath, + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); await process.exited; - + // Process should exit with non-zero code expect(process.exitCode).not.toBe(0); - + // Should output error message about mutual exclusivity const stderr = await new Response(process.stderr).text(); expect(stderr).toContain('mutually exclusive'); }); test('should reject when --enabled-tools is provided without value', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--enabled-tools' - ], { + const process = Bun.spawn([controllerExecutable, '--enabled-tools'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }); await process.exited; - + // Process should exit with non-zero code expect(process.exitCode).not.toBe(0); - + // Should output error message about missing value const stderr = await new Response(process.stderr).text(); expect(stderr).toContain('--enabled-tools requires a value'); }); test('should reject when --disabled-tools is provided without value', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--disabled-tools' - ], { + const process = Bun.spawn([controllerExecutable, '--disabled-tools'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }); await process.exited; - + // Process should exit with non-zero code expect(process.exitCode).not.toBe(0); - + // Should output error message about missing value const stderr = await new Response(process.stderr).text(); expect(stderr).toContain('--disabled-tools requires a value'); }); test('should reject when no target command is provided', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--enabled-tools', 'add' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [controllerExecutable, '--enabled-tools', 'add'], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); await process.exited; - + // Process should exit with non-zero code expect(process.exitCode).not.toBe(0); - + // Should output error message about missing command const stderr = await new Response(process.stderr).text(); expect(stderr).toContain('No target command specified'); }); test('should accept valid --enabled-tools argument', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--enabled-tools', 'add,get-args', - 'echo', 'test' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [controllerExecutable, '--enabled-tools', 'add,get-args', 'echo', 'test'], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); // Give it a moment to start before killing - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); process.kill(); await process.exited; @@ -112,18 +112,23 @@ describe('CLI Argument Validation Tests', () => { }); test('should accept valid --disabled-tools argument', async () => { - const process = Bun.spawn([ - controllerExecutable, - '--disabled-tools', 'dangerous-tool', - 'echo', 'test' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + '--disabled-tools', + 'dangerous-tool', + 'echo', + 'test', + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); // Give it a moment to start before killing - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); process.kill(); await process.exited; @@ -133,4 +138,4 @@ describe('CLI Argument Validation Tests', () => { expect(stderr).not.toContain('requires a value'); expect(stderr).not.toContain('No target command specified'); }); -}); \ No newline at end of file +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index e7c7303..d89f726 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -9,12 +9,16 @@ import { ListResourcesResultSchema, ReadResourceResultSchema, ListPromptsResultSchema, - GetPromptResultSchema + GetPromptResultSchema, } from '@modelcontextprotocol/sdk/types.js'; -import { withMcpCommander, type JsonRpcMessage, type JsonRpcResponse } from './test-utils.js'; -import { - createInitializeRequest, - createInitializedNotification, +import { + withMcpCommander, + type JsonRpcMessage, + type JsonRpcResponse, +} from './test-utils.js'; +import { + createInitializeRequest, + createInitializedNotification, createToolsListRequest, createToolCallRequest, createResourcesListRequest, @@ -23,66 +27,73 @@ import { createPingRequest, createPromptsListRequest, createPromptGetRequest, - createInvalidMethodRequest + createInvalidMethodRequest, } from './test-messages.js'; // Complete response schemas using MCP SDK types const InitializeResponseSchema = JSONRPCResponseSchema.extend({ - result: InitializeResultSchema + result: InitializeResultSchema, }); const ToolsListResponseSchema = JSONRPCResponseSchema.extend({ - result: ListToolsResultSchema + result: ListToolsResultSchema, }); const ToolCallResponseSchema = JSONRPCResponseSchema.extend({ - result: CallToolResultSchema + result: CallToolResultSchema, }); const ResourcesListResponseSchema = JSONRPCResponseSchema.extend({ - result: ListResourcesResultSchema + result: ListResourcesResultSchema, }); const ResourceReadResponseSchema = JSONRPCResponseSchema.extend({ - result: ReadResourceResultSchema + result: ReadResourceResultSchema, }); const PromptsListResponseSchema = JSONRPCResponseSchema.extend({ - result: ListPromptsResultSchema + result: ListPromptsResultSchema, }); const PromptGetResponseSchema = JSONRPCResponseSchema.extend({ - result: GetPromptResultSchema + result: GetPromptResultSchema, }); const ErrorResponseSchema = JSONRPCErrorSchema; - describe('MCP Proxy Integration Tests', () => { - let proxyProcess: Bun.Subprocess<"pipe", "pipe", "pipe">; - + let proxyProcess: Bun.Subprocess<'pipe', 'pipe', 'pipe'>; + const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); - const controllerExecutable = path.resolve('./mcp-controller'); - + const controllerExecutable = path.resolve('./bin/mcp-controller'); + beforeAll(async () => { // Start proxy executable as a subprocess so we can communicate with it via stdio // Pass test arguments to the fixture server (both positional and named) - proxyProcess = Bun.spawn([ - controllerExecutable, - 'bun', 'run', fixtureServerPath, - 'test-arg-1', 'test-arg-2', - '--named-1', 'test-named-1-value', - '--named-2', 'test-named-2-value' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - + proxyProcess = Bun.spawn( + [ + controllerExecutable, + 'bun', + 'run', + fixtureServerPath, + 'test-arg-1', + 'test-arg-2', + '--named-1', + 'test-named-1-value', + '--named-2', + 'test-named-2-value', + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); + // Give the proxy time to start - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); }); - + afterAll(async () => { proxyProcess.kill(); await proxyProcess.exited; @@ -90,12 +101,18 @@ describe('MCP Proxy Integration Tests', () => { function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { if (typeof value !== 'object' || value === null) return false; - return 'jsonrpc' in value && typeof value.jsonrpc === 'string' && - 'id' in value && typeof value.id === 'number'; + return ( + 'jsonrpc' in value && + typeof value.jsonrpc === 'string' && + 'id' in value && + typeof value.id === 'number' + ); } // Helper function to send JSON-RPC message and get response - async function sendJsonRpcMessage(message: JsonRpcMessage): Promise { + async function sendJsonRpcMessage( + message: JsonRpcMessage + ): Promise { const messageStr = JSON.stringify(message) + '\n'; // Type narrowing for Bun.Subprocess - TypeScript needs these checks even though ESLint thinks they're unnecessary @@ -147,7 +164,7 @@ describe('MCP Proxy Integration Tests', () => { const initRequest = createInitializeRequest(); const response = await sendJsonRpcMessage(initRequest); - + // Validate entire response structure const validatedResponse = InitializeResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -183,14 +200,14 @@ describe('MCP Proxy Integration Tests', () => { await sendNotification(initNotification); // Give it time to process - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); }); test('should list tools through proxy', async () => { const toolsRequest = createToolsListRequest(); const response = await sendJsonRpcMessage(toolsRequest); - + // Validate entire response structure const validatedResponse = ToolsListResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -239,7 +256,8 @@ describe('MCP Proxy Integration Tests', () => { { name: 'get-args', title: 'Get Arguments Tool', - description: 'Returns the command line arguments passed to the server', + description: + 'Returns the command line arguments passed to the server', inputSchema: { $schema: 'http://json-schema.org/draft-07/schema#', additionalProperties: false, @@ -256,7 +274,7 @@ describe('MCP Proxy Integration Tests', () => { const toolCallRequest = createToolCallRequest(3, 'add', { a: 5, b: 3 }); const response = await sendJsonRpcMessage(toolCallRequest); - + // Validate entire response structure const validatedResponse = ToolCallResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -277,7 +295,7 @@ describe('MCP Proxy Integration Tests', () => { const argsRequest = createToolCallRequest(13, 'get-args'); const response = await sendJsonRpcMessage(argsRequest); - + // Validate entire response structure including both positional and named arguments const validatedResponse = ToolCallResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -298,7 +316,7 @@ describe('MCP Proxy Integration Tests', () => { const resourcesRequest = createResourcesListRequest(); const response = await sendJsonRpcMessage(resourcesRequest); - + // Validate entire response structure const validatedResponse = ResourcesListResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -318,10 +336,13 @@ describe('MCP Proxy Integration Tests', () => { }); test('should read resources through proxy', async () => { - const resourceReadRequest = createResourceReadRequest(5, 'greeting://world'); + const resourceReadRequest = createResourceReadRequest( + 5, + 'greeting://world' + ); const response = await sendJsonRpcMessage(resourceReadRequest); - + // Validate entire response structure const validatedResponse = ResourceReadResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -342,7 +363,7 @@ describe('MCP Proxy Integration Tests', () => { const invalidToolRequest = createToolCallRequest(6, 'nonexistent-tool'); const response = await sendJsonRpcMessage(invalidToolRequest); - + // Validate entire error response structure const validatedResponse = ErrorResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -359,7 +380,7 @@ describe('MCP Proxy Integration Tests', () => { const invalidRequest = createInvalidMethodRequest(7); const response = await sendJsonRpcMessage(invalidRequest); - + // Validate entire error response structure const validatedResponse = ErrorResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -373,10 +394,13 @@ describe('MCP Proxy Integration Tests', () => { }); test('should handle tool call with invalid arguments', async () => { - const invalidArgsRequest = createToolCallRequest(8, 'add', { a: 'not-a-number', b: 3 }); + const invalidArgsRequest = createToolCallRequest(8, 'add', { + a: 'not-a-number', + b: 3, + }); const response = await sendJsonRpcMessage(invalidArgsRequest); - + // Validate entire error response structure const validatedResponse = ErrorResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -384,7 +408,8 @@ describe('MCP Proxy Integration Tests', () => { id: 8, error: { code: -32602, - message: 'MCP error -32602: Invalid arguments for tool add: [\n {\n "code": "invalid_type",\n "expected": "number",\n "received": "string",\n "path": [\n "a"\n ],\n "message": "Expected number, received string"\n }\n]', + message: + 'MCP error -32602: Invalid arguments for tool add: [\n {\n "code": "invalid_type",\n "expected": "number",\n "received": "string",\n "path": [\n "a"\n ],\n "message": "Expected number, received string"\n }\n]', }, }); }); @@ -393,7 +418,7 @@ describe('MCP Proxy Integration Tests', () => { const invalidUriRequest = createResourceReadRequest(9, 'invalid://uri'); const response = await sendJsonRpcMessage(invalidUriRequest); - + // Validate entire error response structure const validatedResponse = ErrorResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -410,7 +435,7 @@ describe('MCP Proxy Integration Tests', () => { const resourceTemplatesRequest = createResourceTemplatesListRequest(); const response = await sendJsonRpcMessage(resourceTemplatesRequest); - + // Validate entire response structure expect(response).toEqual({ jsonrpc: '2.0', @@ -432,7 +457,7 @@ describe('MCP Proxy Integration Tests', () => { const pingRequest = createPingRequest(); const response = await sendJsonRpcMessage(pingRequest); - + // Validate entire response structure expect(response).toEqual({ jsonrpc: '2.0', @@ -445,7 +470,7 @@ describe('MCP Proxy Integration Tests', () => { const initRequest = createInitializeRequest(12); const response = await sendJsonRpcMessage(initRequest); - + // Validate entire response structure const validatedResponse = InitializeResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -477,7 +502,7 @@ describe('MCP Proxy Integration Tests', () => { const promptsRequest = createPromptsListRequest(); const response = await sendJsonRpcMessage(promptsRequest); - + // Validate entire response structure const validatedResponse = PromptsListResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -502,10 +527,12 @@ describe('MCP Proxy Integration Tests', () => { }); test('should get prompt with arguments through proxy', async () => { - const promptGetRequest = createPromptGetRequest(15, 'generate-greeting', { name: 'Alice' }); + const promptGetRequest = createPromptGetRequest(15, 'generate-greeting', { + name: 'Alice', + }); const response = await sendJsonRpcMessage(promptGetRequest); - + // Validate entire response structure const validatedResponse = PromptGetResponseSchema.parse(response); expect(validatedResponse).toEqual({ @@ -527,105 +554,110 @@ describe('MCP Proxy Integration Tests', () => { }); describe('MCP Proxy Tool Filtering Tests', () => { - describe('enabled tools filtering', () => { test('should only return enabled tools in tools/list response', async () => { - await withMcpCommander(['--enabled-tools', 'add'], async (sendJsonRpcMessage, sendNotification) => { - // Initialize the connection - const initRequest = createInitializeRequest(); - await sendJsonRpcMessage(initRequest); - - const initNotification = createInitializedNotification(); - await sendNotification(initNotification); - - const toolsRequest = createToolsListRequest(); - - const response = await sendJsonRpcMessage(toolsRequest); - - // Validate entire response structure with only 'add' tool (filtering working correctly) - const validatedResponse = ToolsListResponseSchema.parse(response); - expect(validatedResponse).toEqual({ - jsonrpc: '2.0', - id: 2, - result: { - tools: [ - { - name: 'add', - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + await withMcpCommander( + ['--enabled-tools', 'add'], + async (sendJsonRpcMessage, sendNotification) => { + // Initialize the connection + const initRequest = createInitializeRequest(); + await sendJsonRpcMessage(initRequest); + + const initNotification = createInitializedNotification(); + await sendNotification(initNotification); + + const toolsRequest = createToolsListRequest(); + + const response = await sendJsonRpcMessage(toolsRequest); + + // Validate entire response structure with only 'add' tool (filtering working correctly) + const validatedResponse = ToolsListResponseSchema.parse(response); + expect(validatedResponse).toEqual({ + jsonrpc: '2.0', + id: 2, + result: { + tools: [ + { + name: 'add', + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', }, - required: ['a', 'b'], - type: 'object', }, - }, - ], - }, - }); - }); + ], + }, + }); + } + ); }); }); describe('disabled tools filtering', () => { test('should exclude disabled tools from tools/list response', async () => { - await withMcpCommander(['--disabled-tools', 'get-args'], async (sendJsonRpcMessage, sendNotification) => { - // Initialize the connection - const initRequest = createInitializeRequest(); - await sendJsonRpcMessage(initRequest); - - const initNotification = createInitializedNotification(); - await sendNotification(initNotification); - - const toolsRequest = createToolsListRequest(); - - const response = await sendJsonRpcMessage(toolsRequest); - - // Validate entire response structure excluding 'get-args' tool (filtering working correctly) - const validatedResponse = ToolsListResponseSchema.parse(response); - expect(validatedResponse).toEqual({ - jsonrpc: '2.0', - id: 2, - result: { - tools: [ - { - name: 'add', - title: 'Addition Tool', - description: 'Add two numbers', - inputSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + await withMcpCommander( + ['--disabled-tools', 'get-args'], + async (sendJsonRpcMessage, sendNotification) => { + // Initialize the connection + const initRequest = createInitializeRequest(); + await sendJsonRpcMessage(initRequest); + + const initNotification = createInitializedNotification(); + await sendNotification(initNotification); + + const toolsRequest = createToolsListRequest(); + + const response = await sendJsonRpcMessage(toolsRequest); + + // Validate entire response structure excluding 'get-args' tool (filtering working correctly) + const validatedResponse = ToolsListResponseSchema.parse(response); + expect(validatedResponse).toEqual({ + jsonrpc: '2.0', + id: 2, + result: { + tools: [ + { + name: 'add', + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', }, - required: ['a', 'b'], - type: 'object', }, - }, - { - name: 'subtract', - title: 'Subtraction Tool', - description: 'Subtract two numbers', - inputSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - a: { type: 'number' }, - b: { type: 'number' }, + { + name: 'subtract', + title: 'Subtraction Tool', + description: 'Subtract two numbers', + inputSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + a: { type: 'number' }, + b: { type: 'number' }, + }, + required: ['a', 'b'], + type: 'object', }, - required: ['a', 'b'], - type: 'object', }, - }, - ], - }, - }); - }); + ], + }, + }); + } + ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/list-tools.test.ts b/tests/list-tools.test.ts index 76c0b9f..75a454f 100644 --- a/tests/list-tools.test.ts +++ b/tests/list-tools.test.ts @@ -3,96 +3,102 @@ import path from 'path'; describe('List Tools Command Tests', () => { const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); - const controllerExecutable = path.resolve('./mcp-controller'); - + const controllerExecutable = path.resolve('./bin/mcp-controller'); + test('should list all available tools', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [controllerExecutable, 'list-tools', 'bun', 'run', fixtureServerPath], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + await process.exited; - + // Should not have errors expect(errorOutput.trim()).toBe(''); - + // Should list all tools in the expected format const lines = output.trim().split('\n'); expect(lines).toEqual([ 'add: Add two numbers', 'subtract: Subtract two numbers', - 'get-args: Returns the command line arguments passed to the server' + 'get-args: Returns the command line arguments passed to the server', ]); }); test('should list only enabled tools when --enabled-tools is specified', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - '--enabled-tools', 'add', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + 'list-tools', + '--enabled-tools', + 'add', + 'bun', + 'run', + fixtureServerPath, + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + await process.exited; - + // Should not have errors expect(errorOutput.trim()).toBe(''); - + // Should only list the enabled tool const lines = output.trim().split('\n'); - expect(lines).toEqual([ - 'add: Add two numbers' - ]); + expect(lines).toEqual(['add: Add two numbers']); }); test('should exclude disabled tools when --disabled-tools is specified', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - '--disabled-tools', 'get-args', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + 'list-tools', + '--disabled-tools', + 'get-args', + 'bun', + 'run', + fixtureServerPath, + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + await process.exited; - + // Should not have errors expect(errorOutput.trim()).toBe(''); - - // Should only list the non-disabled tools + + // Should only list the non-disabled tools const lines = output.trim().split('\n'); expect(lines).toEqual([ 'add: Add two numbers', - 'subtract: Subtract two numbers' + 'subtract: Subtract two numbers', ]); }); test('should show error when no target command specified', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools' - ], { + const process = Bun.spawn([controllerExecutable, 'list-tools'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', @@ -100,100 +106,117 @@ describe('List Tools Command Tests', () => { const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + const exitCode = await process.exited; - + // Should exit with error code expect(exitCode).toBe(1); - + // Should have error message - expect(errorOutput.trim()).toBe('Error: No target command specified for list-tools'); - + expect(errorOutput.trim()).toBe( + 'Error: No target command specified for list-tools' + ); + // Should have no stdout output expect(output.trim()).toBe(''); }); test('should handle server initialization errors', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - 'nonexistent-command' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [controllerExecutable, 'list-tools', 'nonexistent-command'], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + const exitCode = await process.exited; - + // Should exit with error code expect(exitCode).toBe(1); - + // Should have error message about listing tools expect(errorOutput).toContain('Error listing tools:'); - + // Should have no stdout output expect(output.trim()).toBe(''); }); test('should handle mutually exclusive enabled/disabled tools arguments', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - '--enabled-tools', 'add', - '--disabled-tools', 'get-args', - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + 'list-tools', + '--enabled-tools', + 'add', + '--disabled-tools', + 'get-args', + 'bun', + 'run', + fixtureServerPath, + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + const exitCode = await process.exited; - + // Should exit with error code expect(exitCode).toBe(1); - + // Should have error message about mutual exclusivity - expect(errorOutput.trim()).toBe('Error: --enabled-tools and --disabled-tools are mutually exclusive'); - + expect(errorOutput.trim()).toBe( + 'Error: --enabled-tools and --disabled-tools are mutually exclusive' + ); + // Should have no stdout output expect(output.trim()).toBe(''); }); test('should pass command line arguments to target server during list-tools', async () => { - const process = Bun.spawn([ - controllerExecutable, - 'list-tools', - 'bun', 'run', fixtureServerPath, - 'pos-arg-1', 'pos-arg-2', - '--named-arg', 'named-value' - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + const process = Bun.spawn( + [ + controllerExecutable, + 'list-tools', + 'bun', + 'run', + fixtureServerPath, + 'pos-arg-1', + 'pos-arg-2', + '--named-arg', + 'named-value', + ], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); const output = await new Response(process.stdout).text(); const errorOutput = await new Response(process.stderr).text(); - + await process.exited; - + // Should not have errors expect(errorOutput.trim()).toBe(''); - + // Should list tools normally (arguments don't affect tool listing) const lines = output.trim().split('\n'); expect(lines).toEqual([ 'add: Add two numbers', 'subtract: Subtract two numbers', - 'get-args: Returns the command line arguments passed to the server' + 'get-args: Returns the command line arguments passed to the server', ]); }); -}); \ No newline at end of file +}); diff --git a/tests/setup.ts b/tests/setup.ts index acad129..a94f3de 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,18 +1,37 @@ import { beforeAll } from 'bun:test'; beforeAll(async () => { - // Build the executable before running tests - process.stderr.write('Building mcp-controller executable...\n'); + process.stderr.write('Building mcp-controller test binary...\n'); - const buildProcess = Bun.spawn(['bun', 'run', 'build'], { - stdout: 'inherit', - stderr: 'inherit', - }); + const buildProcess = Bun.spawn( + [ + 'bun', + 'build', + 'src/cli.ts', + '--compile', + '--outfile', + './bin/mcp-controller-downloaded', + ], + { + stdout: 'inherit', + stderr: 'inherit', + } + ); const exitCode = await buildProcess.exited; if (exitCode !== 0) { throw new Error(`Build failed with exit code ${exitCode}`); } + await Bun.spawn(['chmod', '+x', './bin/mcp-controller'], { + stdout: 'inherit', + stderr: 'inherit', + }).exited; + + await Bun.spawn(['chmod', '+x', './bin/mcp-controller-downloaded'], { + stdout: 'inherit', + stderr: 'inherit', + }).exited; + process.stderr.write('Build complete. Running tests...\n'); -}); \ No newline at end of file +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ad43929..629bba1 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -20,34 +20,42 @@ type JsonRpcResponse = { function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { if (typeof value !== 'object' || value === null) return false; - return 'jsonrpc' in value && typeof value.jsonrpc === 'string' && - 'id' in value && typeof value.id === 'number'; + return ( + 'jsonrpc' in value && + typeof value.jsonrpc === 'string' && + 'id' in value && + typeof value.id === 'number' + ); } const fixtureServerPath = path.resolve('./tests/fixtures/mcp-server.ts'); -const controllerExecutable = path.resolve('./mcp-controller'); +const controllerExecutable = path.resolve('./bin/mcp-controller'); // Helper function to manage MCP Commander process lifecycle export async function withMcpCommander( args: string[], - callback: (sendJsonRpcMessage: (message: JsonRpcMessage) => Promise, sendNotification: (message: JsonRpcMessage) => Promise) => Promise + callback: ( + sendJsonRpcMessage: (message: JsonRpcMessage) => Promise, + sendNotification: (message: JsonRpcMessage) => Promise + ) => Promise ): Promise { - const proxyProcess = Bun.spawn([ - controllerExecutable, - ...args, - 'bun', 'run', fixtureServerPath - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - + const proxyProcess = Bun.spawn( + [controllerExecutable, ...args, 'bun', 'run', fixtureServerPath], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + } + ); + try { // Give the proxy time to start - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Helper function to send JSON-RPC message and get response - async function sendJsonRpcMessage(message: JsonRpcMessage): Promise { + async function sendJsonRpcMessage( + message: JsonRpcMessage + ): Promise { const messageStr = JSON.stringify(message) + '\n'; // Write to stdin (FileSink in Bun) @@ -89,4 +97,4 @@ export async function withMcpCommander( } } -export type { JsonRpcMessage, JsonRpcResponse }; \ No newline at end of file +export type { JsonRpcMessage, JsonRpcResponse }; diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..44898e2 --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,203 @@ +import { test, describe, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { join } from 'node:path'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir, platform, arch } from 'node:os'; +import { + getBinaryName, + isPrerelease, + isNewerVersion, + getPlatform, + getArchitecture, + fetchLatestVersion, + downloadBinary, + replaceBinary, +} from '../src/update.js'; + +describe('getBinaryName', () => { + test('darwin-arm64', () => { + expect(getBinaryName('darwin', 'arm64')).toBe( + 'mcp-controller-darwin-arm64' + ); + }); + + test('darwin-x64', () => { + expect(getBinaryName('darwin', 'x64')).toBe('mcp-controller-darwin-x64'); + }); + + test('linux-arm64', () => { + expect(getBinaryName('linux', 'arm64')).toBe('mcp-controller-linux-arm64'); + }); + + test('linux-x64', () => { + expect(getBinaryName('linux', 'x64')).toBe('mcp-controller-linux-x64'); + }); + + test('windows-x64', () => { + expect(getBinaryName('windows', 'x64')).toBe( + 'mcp-controller-windows-x64.exe' + ); + }); +}); + +describe('isPrerelease', () => { + test('returns false for stable version', () => { + expect(isPrerelease('1.2.3')).toBe(false); + }); + + test('returns true for prerelease version', () => { + expect(isPrerelease('1.2.3-beta.1')).toBe(true); + }); +}); + +describe('isNewerVersion', () => { + test('returns true when latest version is higher', () => { + expect(isNewerVersion('1.0.0', '1.0.1')).toBe(true); + }); + + test('returns false when current version is same', () => { + expect(isNewerVersion('1.0.0', '1.0.0')).toBe(false); + }); +}); + +describe('getPlatform', () => { + test('returns a valid platform on this machine', () => { + const result = getPlatform(); + expect(result.success).toBe(true); + if (result.success) { + expect(['darwin', 'linux', 'windows'].includes(result.data)).toBe(true); + } + }); +}); + +describe('getArchitecture', () => { + test('returns a valid architecture on this machine', () => { + const result = getArchitecture(); + expect(result.success).toBe(true); + if (result.success) { + expect(['x64', 'arm64'].includes(result.data)).toBe(true); + } + }); +}); + +describe('fetchLatestVersion', () => { + const originalFetch = globalThis.fetch; + + function createMockFetch(response: Response) { + const mockFn = mock(() => Promise.resolve(response)); + return Object.assign(mockFn, { preconnect: originalFetch.preconnect }); + } + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test('returns version and download URL on success', async () => { + globalThis.fetch = createMockFetch( + new Response(JSON.stringify({ tag_name: 'v2.0.0' }), { status: 200 }) + ); + + const currentPlatform = + platform() === 'win32' + ? 'windows' + : platform() === 'darwin' + ? 'darwin' + : 'linux'; + const currentArch = arch() === 'arm64' ? 'arm64' : 'x64'; + const expectedBinary = + currentPlatform === 'windows' + ? `mcp-controller-${currentPlatform}-${currentArch}.exe` + : `mcp-controller-${currentPlatform}-${currentArch}`; + + const result = await fetchLatestVersion(); + expect(result).toEqual({ + success: true, + data: { + version: '2.0.0', + downloadUrl: `https://github.com/eli0shin/mcp-controller/releases/latest/download/${expectedBinary}`, + }, + }); + }); + + test('returns error on 404', async () => { + globalThis.fetch = createMockFetch(new Response('', { status: 404 })); + + const result = await fetchLatestVersion(); + expect(result).toEqual({ + success: false, + error: 'No releases found', + }); + }); +}); + +describe('downloadBinary', () => { + const originalFetch = globalThis.fetch; + let tempDir: string; + + function createMockFetch(response: Response) { + const mockFn = mock(() => Promise.resolve(response)); + return Object.assign(mockFn, { preconnect: originalFetch.preconnect }); + } + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'update-download-test-')); + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + await rm(tempDir, { recursive: true, force: true }); + }); + + test('downloads binary to temp file', async () => { + const binaryContent = Buffer.from('fake-binary-content'); + globalThis.fetch = createMockFetch( + new Response(binaryContent, { status: 200 }) + ); + + const result = await downloadBinary('https://example.com/binary', tempDir); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toContain('.mcp-controller-update-'); + const content = await Bun.file(result.data).text(); + expect(content).toBe('fake-binary-content'); + const fileStat = await stat(result.data); + expect(fileStat.mode & 0o777).toBe(0o755); + } + }); + + test('returns error on 404', async () => { + globalThis.fetch = createMockFetch( + new Response('Not found', { status: 404 }) + ); + + const result = await downloadBinary('https://example.com/binary', tempDir); + expect(result).toEqual({ + success: false, + error: 'Binary not found for this platform', + }); + }); +}); + +describe('replaceBinary', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'update-replace-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test('replaces target with temp file', async () => { + const tempPath = join(tempDir, 'temp-binary'); + const targetPath = join(tempDir, 'target-binary'); + await Bun.write(tempPath, 'new-content'); + await Bun.write(targetPath, 'old-content'); + + const result = await replaceBinary(tempPath, targetPath); + expect(result).toEqual({ success: true, data: undefined }); + + const content = await Bun.file(targetPath).text(); + expect(content).toBe('new-content'); + }); +}); diff --git a/tests/updater-worker.test.ts b/tests/updater-worker.test.ts new file mode 100644 index 0000000..f75668e --- /dev/null +++ b/tests/updater-worker.test.ts @@ -0,0 +1,117 @@ +import { test, describe, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { runUpdaterWorker } from '../src/updater-worker.js'; +import type { WorkerDeps } from '../src/updater-worker.js'; + +let tempDir: string; +let originalArgv: string[]; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'updater-worker-test-')); + originalArgv = process.argv; +}); + +afterEach(async () => { + process.argv = originalArgv; + await rm(tempDir, { recursive: true, force: true }); +}); + +function createMockDeps(overrides: Partial = {}): WorkerDeps { + return { + fetchLatestVersion: mock(async () => ({ + success: true as const, + data: { version: '2.0.0', downloadUrl: 'https://example.com/binary' }, + })), + downloadBinary: mock(async () => ({ + success: true as const, + data: join(tempDir, 'temp-binary'), + })), + replaceBinary: mock(async () => ({ + success: true as const, + data: undefined, + })), + writeUpdateState: mock(async () => ({ + success: true as const, + data: undefined, + })), + getUpdateStatePath: () => join(tempDir, 'update-state'), + ...overrides, + }; +} + +describe('runUpdaterWorker', () => { + test('returns early when args are missing', async () => { + process.argv = ['bun', 'script', '--update-worker']; + const deps = createMockDeps(); + + await runUpdaterWorker(deps); + + expect(deps.fetchLatestVersion).toHaveBeenCalledTimes(0); + }); + + test('downloads and replaces when newer version is available', async () => { + const binaryPath = join(tempDir, 'mcp-controller'); + process.argv = [ + 'bun', + 'script', + '--update-worker', + '1.0.0', + binaryPath, + 'auto', + ]; + const deps = createMockDeps(); + + await runUpdaterWorker(deps); + + expect(deps.fetchLatestVersion).toHaveBeenCalledTimes(1); + expect(deps.downloadBinary).toHaveBeenCalledTimes(1); + expect(deps.replaceBinary).toHaveBeenCalledTimes(1); + expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + }); + + test('skips update when version is not newer', async () => { + const binaryPath = join(tempDir, 'mcp-controller'); + process.argv = [ + 'bun', + 'script', + '--update-worker', + '3.0.0', + binaryPath, + 'auto', + ]; + const deps = createMockDeps(); + + await runUpdaterWorker(deps); + + expect(deps.downloadBinary).toHaveBeenCalledTimes(0); + expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + }); + + test('skips prerelease versions', async () => { + const binaryPath = join(tempDir, 'mcp-controller'); + process.argv = [ + 'bun', + 'script', + '--update-worker', + '1.0.0', + binaryPath, + 'auto', + ]; + const deps = createMockDeps({ + fetchLatestVersion: mock(async () => ({ + success: true as const, + data: { + version: '2.0.0-beta.1', + downloadUrl: 'https://example.com/binary', + }, + })), + }); + + await runUpdaterWorker(deps); + + expect(deps.downloadBinary).toHaveBeenCalledTimes(0); + expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3fdfa82..bda718a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,11 +12,6 @@ "forceConsistentCasingInFileNames": true, "types": ["bun-types", "node"] }, - "include": [ - "src/**/*", - "tests/**/*" - ], - "exclude": [ - "node_modules/**/*" - ] -} \ No newline at end of file + "include": ["src/**/*", "script/**/*", "tests/**/*"], + "exclude": ["node_modules/**/*"] +} From de279b8a532b720dc24e373910a8a38a221ec870 Mon Sep 17 00:00:00 2001 From: Eli Oshinsky Date: Tue, 17 Mar 2026 22:13:17 -0400 Subject: [PATCH 2/2] fix: unblock release migration checks --- .changeset/green-snakes-juggle.md | 5 ++ eslint.config.mjs | 2 +- src/updater-worker.ts | 4 +- tests/updater-worker.test.ts | 124 +++++++++++++++++++----------- 4 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 .changeset/green-snakes-juggle.md diff --git a/.changeset/green-snakes-juggle.md b/.changeset/green-snakes-juggle.md new file mode 100644 index 0000000..652a3b8 --- /dev/null +++ b/.changeset/green-snakes-juggle.md @@ -0,0 +1,5 @@ +--- +'mcp-controller': patch +--- + +Fix the published CLI distribution so installs fetch the right platform binary from GitHub Releases and add the release, update, and verification flow that supports the new packaging model. diff --git a/eslint.config.mjs b/eslint.config.mjs index f6ff9dd..d4338ba 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,7 @@ export default [ 'import-x/resolver': { typescript: true, }, - 'import-x/core-modules': ['bun:test'], + 'import-x/core-modules': ['bun', 'bun:test'], }, rules: { 'for-ai/no-standalone-class': 'off', diff --git a/src/updater-worker.ts b/src/updater-worker.ts index 429e29d..f146939 100644 --- a/src/updater-worker.ts +++ b/src/updater-worker.ts @@ -17,13 +17,13 @@ export type WorkerDeps = { getUpdateStatePath: typeof getUpdateStatePath; }; -const defaultDeps: WorkerDeps = { +const defaultDeps = { fetchLatestVersion, downloadBinary, replaceBinary, writeUpdateState, getUpdateStatePath, -}; +} satisfies WorkerDeps; export async function runUpdaterWorker( deps: WorkerDeps = defaultDeps diff --git a/tests/updater-worker.test.ts b/tests/updater-worker.test.ts index f75668e..47eda50 100644 --- a/tests/updater-worker.test.ts +++ b/tests/updater-worker.test.ts @@ -3,56 +3,72 @@ import { join } from 'node:path'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { runUpdaterWorker } from '../src/updater-worker.js'; +import { replaceBinary } from '../src/update.js'; +import { writeUpdateState } from '../src/update-state.js'; import type { WorkerDeps } from '../src/updater-worker.js'; +const FIXED_TIMESTAMP = 1_700_000_000_000; + let tempDir: string; let originalArgv: string[]; +let originalDateNow: typeof Date.now; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'updater-worker-test-')); originalArgv = process.argv; + originalDateNow = Date.now; + Date.now = () => FIXED_TIMESTAMP; }); afterEach(async () => { process.argv = originalArgv; + Date.now = originalDateNow; await rm(tempDir, { recursive: true, force: true }); }); -function createMockDeps(overrides: Partial = {}): WorkerDeps { +function createWorkerDeps(overrides: Partial = {}): WorkerDeps { + const statePath = join(tempDir, 'update-state.json'); + return { fetchLatestVersion: mock(async () => ({ success: true as const, data: { version: '2.0.0', downloadUrl: 'https://example.com/binary' }, })), - downloadBinary: mock(async () => ({ - success: true as const, - data: join(tempDir, 'temp-binary'), - })), - replaceBinary: mock(async () => ({ - success: true as const, - data: undefined, - })), - writeUpdateState: mock(async () => ({ - success: true as const, - data: undefined, - })), - getUpdateStatePath: () => join(tempDir, 'update-state'), + downloadBinary: mock(async () => { + const tempBinaryPath = join(tempDir, 'downloaded-binary'); + await Bun.write(tempBinaryPath, 'new-content'); + return { + success: true as const, + data: tempBinaryPath, + }; + }), + replaceBinary, + writeUpdateState, + getUpdateStatePath: () => statePath, ...overrides, }; } +async function readStateFile(statePath: string): Promise { + const stateText = await Bun.file(statePath).text(); + return JSON.parse(stateText); +} + describe('runUpdaterWorker', () => { - test('returns early when args are missing', async () => { + test('returns early without writing state when args are missing', async () => { process.argv = ['bun', 'script', '--update-worker']; - const deps = createMockDeps(); + const deps = createWorkerDeps(); await runUpdaterWorker(deps); - expect(deps.fetchLatestVersion).toHaveBeenCalledTimes(0); + const stateExists = await Bun.file(deps.getUpdateStatePath()).exists(); + expect(stateExists).toBe(false); }); - test('downloads and replaces when newer version is available', async () => { + test('replaces the binary and writes update state when a newer version exists', async () => { const binaryPath = join(tempDir, 'mcp-controller'); + await Bun.write(binaryPath, 'old-content'); + process.argv = [ 'bun', 'script', @@ -61,18 +77,24 @@ describe('runUpdaterWorker', () => { binaryPath, 'auto', ]; - const deps = createMockDeps(); - await runUpdaterWorker(deps); + await runUpdaterWorker(createWorkerDeps()); - expect(deps.fetchLatestVersion).toHaveBeenCalledTimes(1); - expect(deps.downloadBinary).toHaveBeenCalledTimes(1); - expect(deps.replaceBinary).toHaveBeenCalledTimes(1); - expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + const binaryContent = await Bun.file(binaryPath).text(); + const stateContent = await readStateFile( + join(tempDir, 'update-state.json') + ); + + expect(binaryContent).toBe('new-content'); + expect(stateContent).toEqual({ + lastCheckedAt: FIXED_TIMESTAMP, + }); }); - test('skips update when version is not newer', async () => { + test('keeps the current binary and still writes state when already up to date', async () => { const binaryPath = join(tempDir, 'mcp-controller'); + await Bun.write(binaryPath, 'current-content'); + process.argv = [ 'bun', 'script', @@ -81,16 +103,24 @@ describe('runUpdaterWorker', () => { binaryPath, 'auto', ]; - const deps = createMockDeps(); - await runUpdaterWorker(deps); + await runUpdaterWorker(createWorkerDeps()); + + const binaryContent = await Bun.file(binaryPath).text(); + const stateContent = await readStateFile( + join(tempDir, 'update-state.json') + ); - expect(deps.downloadBinary).toHaveBeenCalledTimes(0); - expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + expect(binaryContent).toBe('current-content'); + expect(stateContent).toEqual({ + lastCheckedAt: FIXED_TIMESTAMP, + }); }); - test('skips prerelease versions', async () => { + test('skips prerelease updates and writes state without replacing the binary', async () => { const binaryPath = join(tempDir, 'mcp-controller'); + await Bun.write(binaryPath, 'stable-content'); + process.argv = [ 'bun', 'script', @@ -99,19 +129,27 @@ describe('runUpdaterWorker', () => { binaryPath, 'auto', ]; - const deps = createMockDeps({ - fetchLatestVersion: mock(async () => ({ - success: true as const, - data: { - version: '2.0.0-beta.1', - downloadUrl: 'https://example.com/binary', - }, - })), - }); - await runUpdaterWorker(deps); - - expect(deps.downloadBinary).toHaveBeenCalledTimes(0); - expect(deps.writeUpdateState).toHaveBeenCalledTimes(1); + await runUpdaterWorker( + createWorkerDeps({ + fetchLatestVersion: mock(async () => ({ + success: true as const, + data: { + version: '2.0.0-beta.1', + downloadUrl: 'https://example.com/binary', + }, + })), + }) + ); + + const binaryContent = await Bun.file(binaryPath).text(); + const stateContent = await readStateFile( + join(tempDir, 'update-state.json') + ); + + expect(binaryContent).toBe('stable-content'); + expect(stateContent).toEqual({ + lastCheckedAt: FIXED_TIMESTAMP, + }); }); });