diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c27a0d..471aff6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,8 @@ on: branches: [ main, develop ] jobs: - test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + rust: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -38,37 +34,18 @@ jobs: - name: Rust tests run: cargo test --no-default-features - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ubuntu-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-coverage- - - - uses: dtolnay/rust-toolchain@stable - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin --locked - + run: cargo install cargo-tarpaulin + - name: Prepare tarpaulin output directory - run: mkdir -p coverage - + run: mkdir -p target/tarpaulin + - name: Coverage (tarpaulin) - run: cargo tarpaulin --all-targets --no-default-features --timeout 300 --out Xml --output-dir coverage --skip-clean - continue-on-error: true - + run: cargo tarpaulin --all-targets --no-default-features --timeout 120 --out Lcov --output-dir target/tarpaulin + - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: - name: coverage-reports - path: coverage/ - if-no-files-found: ignore + name: coverage-lcov + path: target/tarpaulin/tarpaulin-report.lcov + if-no-files-found: error diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 10cc08c..f8b38c4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -40,4 +40,4 @@ jobs: actions: read claude_args: | - --system-prompt "You are Claude, an AI assistant for rmcp-mux - a Rust MCP server multiplexer library. STYLE RULES: Be concise and professional. DO NOT use emojis. Focus on technical accuracy. Use code blocks for examples. Reference specific files and line numbers. When reviewing PRs: provide concrete, actionable feedback with specific code suggestions." + --system-prompt "You are Claude, an AI assistant for rust-mux - a Rust MCP server multiplexer library. STYLE RULES: Be concise and professional. DO NOT use emojis. Focus on technical accuracy. Use code blocks for examples. Reference specific files and line numbers. When reviewing PRs: provide concrete, actionable feedback with specific code suggestions." diff --git a/.github/workflows/gemini-dispatch.yml b/.github/workflows/gemini-dispatch.yml deleted file mode 100644 index 22d0b27..0000000 --- a/.github/workflows/gemini-dispatch.yml +++ /dev/null @@ -1,204 +0,0 @@ -name: 'πŸ”€ Gemini Dispatch' - -on: - pull_request_review_comment: - types: - - 'created' - pull_request_review: - types: - - 'submitted' - pull_request: - types: - - 'opened' - issues: - types: - - 'opened' - - 'reopened' - issue_comment: - types: - - 'created' - -defaults: - run: - shell: 'bash' - -jobs: - debugger: - if: |- - ${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }} - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - steps: - - name: 'Print context for debugging' - env: - DEBUG_event_name: '${{ github.event_name }}' - DEBUG_event__action: '${{ github.event.action }}' - DEBUG_event__comment__author_association: '${{ github.event.comment.author_association }}' - DEBUG_event__issue__author_association: '${{ github.event.issue.author_association }}' - DEBUG_event__pull_request__author_association: '${{ github.event.pull_request.author_association }}' - DEBUG_event__review__author_association: '${{ github.event.review.author_association }}' - DEBUG_event: '${{ toJSON(github.event) }}' - run: |- - env | grep '^DEBUG_' - - dispatch: - # For PRs: only if not from a fork - # For issues: only on open/reopen - # For comments: only if user types @gemini-cli and is OWNER/MEMBER/COLLABORATOR - if: |- - ( - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.fork == false - ) || ( - github.event_name == 'issues' && - contains(fromJSON('["opened", "reopened"]'), github.event.action) - ) || ( - github.event.sender.type == 'User' && - startsWith(github.event.comment.body || github.event.review.body || github.event.issue.body, '@gemini-cli') && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) - ) - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - issues: 'write' - pull-requests: 'write' - outputs: - command: '${{ steps.extract_command.outputs.command }}' - request: '${{ steps.extract_command.outputs.request }}' - additional_context: '${{ steps.extract_command.outputs.additional_context }}' - issue_number: '${{ github.event.pull_request.number || github.event.issue.number }}' - steps: - - name: 'Mint identity token' - id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ vars.APP_ID }}' - private-key: '${{ secrets.APP_PRIVATE_KEY }}' - permission-contents: 'read' - permission-issues: 'write' - permission-pull-requests: 'write' - - - name: 'Extract command' - id: 'extract_command' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 - env: - EVENT_TYPE: '${{ github.event_name }}.${{ github.event.action }}' - REQUEST: '${{ github.event.comment.body || github.event.review.body || github.event.issue.body }}' - with: - script: | - const eventType = process.env.EVENT_TYPE; - const request = process.env.REQUEST; - core.setOutput('request', request); - - if (eventType === 'pull_request.opened') { - core.setOutput('command', 'review'); - } else if (['issues.opened', 'issues.reopened'].includes(eventType)) { - core.setOutput('command', 'triage'); - } else if (request.startsWith("@gemini-cli /review")) { - core.setOutput('command', 'review'); - const additionalContext = request.replace(/^@gemini-cli \/review/, '').trim(); - core.setOutput('additional_context', additionalContext); - } else if (request.startsWith("@gemini-cli /triage")) { - core.setOutput('command', 'triage'); - } else if (request.startsWith("@gemini-cli")) { - const additionalContext = request.replace(/^@gemini-cli/, '').trim(); - core.setOutput('command', 'invoke'); - core.setOutput('additional_context', additionalContext); - } else { - core.setOutput('command', 'fallthrough'); - } - - - name: 'Acknowledge request' - env: - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' - ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - MESSAGE: |- - πŸ€– Hi @${{ github.actor }}, I've received your request, and I'm working on it now! You can track my progress [in the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. - REPOSITORY: '${{ github.repository }}' - run: |- - gh issue comment "${ISSUE_NUMBER}" \ - --body "${MESSAGE}" \ - --repo "${REPOSITORY}" - - review: - needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'review' }} - uses: './.github/workflows/gemini-review.yml' - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - with: - additional_context: '${{ needs.dispatch.outputs.additional_context }}' - secrets: 'inherit' - - triage: - needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'triage' }} - uses: './.github/workflows/gemini-triage.yml' - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - with: - additional_context: '${{ needs.dispatch.outputs.additional_context }}' - secrets: 'inherit' - - invoke: - needs: 'dispatch' - if: |- - ${{ needs.dispatch.outputs.command == 'invoke' }} - uses: './.github/workflows/gemini-invoke.yml' - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - with: - additional_context: '${{ needs.dispatch.outputs.additional_context }}' - secrets: 'inherit' - - fallthrough: - needs: - - 'dispatch' - - 'review' - - 'triage' - - 'invoke' - if: |- - ${{ always() && !cancelled() && (failure() || needs.dispatch.outputs.command == 'fallthrough') }} - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - issues: 'write' - pull-requests: 'write' - steps: - - name: 'Mint identity token' - id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ vars.APP_ID }}' - private-key: '${{ secrets.APP_PRIVATE_KEY }}' - permission-contents: 'read' - permission-issues: 'write' - permission-pull-requests: 'write' - - - name: 'Send failure comment' - env: - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' - ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - MESSAGE: |- - πŸ€– I'm sorry @${{ github.actor }}, but I was unable to process your request. Please [see the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. - REPOSITORY: '${{ github.repository }}' - run: |- - gh issue comment "${ISSUE_NUMBER}" \ - --body "${MESSAGE}" \ - --repo "${REPOSITORY}" diff --git a/.github/workflows/gemini-invoke.yml b/.github/workflows/gemini-invoke.yml deleted file mode 100644 index e59e55d..0000000 --- a/.github/workflows/gemini-invoke.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: '▢️ Gemini Invoke' - -on: - workflow_call: - inputs: - additional_context: - type: 'string' - description: 'Any additional context from the request' - required: false - -concurrency: - group: '${{ github.workflow }}-invoke-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' - cancel-in-progress: false - -defaults: - run: - shell: 'bash' - -jobs: - invoke: - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - steps: - - name: 'Mint identity token' - id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ vars.APP_ID }}' - private-key: '${{ secrets.APP_PRIVATE_KEY }}' - permission-contents: 'read' - permission-issues: 'write' - permission-pull-requests: 'write' - - - name: 'Run Gemini CLI' - id: 'run_gemini' - uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude - env: - TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' - DESCRIPTION: '${{ github.event.pull_request.body || github.event.issue.body }}' - EVENT_NAME: '${{ github.event_name }}' - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' - IS_PULL_REQUEST: '${{ !!github.event.pull_request }}' - ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - ADDITIONAL_CONTEXT: '${{ inputs.additional_context }}' - with: - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - gemini_cli_version: '${{ vars.GEMINI_CLI_VERSION }}' - gemini_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' - gemini_model: '${{ vars.GEMINI_MODEL }}' - google_api_key: '${{ secrets.GOOGLE_API_KEY }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' - workflow_name: 'gemini-invoke' - settings: |- - { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".gemini/telemetry.log" - }, - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v0.18.0" - ], - "includeTools": [ - "add_issue_comment", - "get_issue", - "get_issue_comments", - "list_issues", - "search_issues", - "create_pull_request", - "pull_request_read", - "list_pull_requests", - "search_pull_requests", - "create_branch", - "create_or_update_file", - "delete_file", - "fork_repository", - "get_commit", - "get_file_contents", - "list_commits", - "push_files", - "search_code" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" - } - } - }, - "tools": { - "core": [ - "run_shell_command(cat)", - "run_shell_command(echo)", - "run_shell_command(grep)", - "run_shell_command(head)", - "run_shell_command(tail)" - ] - } - } - prompt: '/gemini-invoke' diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml deleted file mode 100644 index d3b43a1..0000000 --- a/.github/workflows/gemini-review.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: 'πŸ”Ž Gemini Review' - -on: - workflow_call: - inputs: - additional_context: - type: 'string' - description: 'Any additional context from the request' - required: false - -concurrency: - group: '${{ github.workflow }}-review-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -jobs: - review: - runs-on: 'ubuntu-latest' - timeout-minutes: 7 - permissions: - contents: 'read' - id-token: 'write' - issues: 'write' - pull-requests: 'write' - steps: - - name: 'Mint identity token' - id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ vars.APP_ID }}' - private-key: '${{ secrets.APP_PRIVATE_KEY }}' - permission-contents: 'read' - permission-issues: 'write' - permission-pull-requests: 'write' - - - name: 'Checkout repository' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Run Gemini pull request review' - uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude - id: 'gemini_pr_review' - env: - GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' - ISSUE_TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' - ISSUE_BODY: '${{ github.event.pull_request.body || github.event.issue.body }}' - PULL_REQUEST_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' - REPOSITORY: '${{ github.repository }}' - ADDITIONAL_CONTEXT: '${{ inputs.additional_context }}' - with: - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - gemini_cli_version: '${{ vars.GEMINI_CLI_VERSION }}' - gemini_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' - gemini_model: '${{ vars.GEMINI_MODEL }}' - google_api_key: '${{ secrets.GOOGLE_API_KEY }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' - workflow_name: 'gemini-review' - settings: |- - { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".gemini/telemetry.log" - }, - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v0.18.0" - ], - "includeTools": [ - "add_comment_to_pending_review", - "create_pending_pull_request_review", - "pull_request_read", - "submit_pending_pull_request_review" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" - } - } - }, - "tools": { - "core": [ - "run_shell_command(cat)", - "run_shell_command(echo)", - "run_shell_command(grep)", - "run_shell_command(head)", - "run_shell_command(tail)" - ] - } - } - prompt: '/gemini-review' diff --git a/.github/workflows/gemini-triage.yml b/.github/workflows/gemini-triage.yml deleted file mode 100644 index 581acbb..0000000 --- a/.github/workflows/gemini-triage.yml +++ /dev/null @@ -1,158 +0,0 @@ -name: 'πŸ”€ Gemini Triage' - -on: - workflow_call: - inputs: - additional_context: - type: 'string' - description: 'Any additional context from the request' - required: false - -concurrency: - group: '${{ github.workflow }}-triage-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' - cancel-in-progress: true - -defaults: - run: - shell: 'bash' - -jobs: - triage: - runs-on: 'ubuntu-latest' - timeout-minutes: 7 - outputs: - available_labels: '${{ steps.get_labels.outputs.available_labels }}' - selected_labels: '${{ env.SELECTED_LABELS }}' - permissions: - contents: 'read' - id-token: 'write' - issues: 'read' - pull-requests: 'read' - steps: - - name: 'Get repository labels' - id: 'get_labels' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 - with: - # NOTE: we intentionally do not use the given token. The default - # GITHUB_TOKEN provided by the action has enough permissions to read - # the labels. - script: |- - const labels = []; - for await (const response of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, // Maximum per page to reduce API calls - })) { - labels.push(...response.data); - } - - if (!labels || labels.length === 0) { - core.setFailed('There are no issue labels in this repository.') - } - - const labelNames = labels.map(label => label.name).sort(); - core.setOutput('available_labels', labelNames.join(',')); - core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); - return labelNames; - - - name: 'Run Gemini issue analysis' - id: 'gemini_analysis' - if: |- - ${{ steps.get_labels.outputs.available_labels != '' }} - uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude - env: - GITHUB_TOKEN: '' # Do NOT pass any auth tokens here since this runs on untrusted inputs - ISSUE_TITLE: '${{ github.event.issue.title }}' - ISSUE_BODY: '${{ github.event.issue.body }}' - AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' - with: - gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' - gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' - gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' - gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' - gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' - gemini_cli_version: '${{ vars.GEMINI_CLI_VERSION }}' - gemini_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' - gemini_model: '${{ vars.GEMINI_MODEL }}' - google_api_key: '${{ secrets.GOOGLE_API_KEY }}' - use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' - use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' - upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' - workflow_name: 'gemini-triage' - settings: |- - { - "model": { - "maxSessionTurns": 25 - }, - "telemetry": { - "enabled": true, - "target": "local", - "outfile": ".gemini/telemetry.log" - }, - "tools": { - "core": [ - "run_shell_command(echo)" - ] - } - } - prompt: '/gemini-triage' - - label: - runs-on: 'ubuntu-latest' - needs: - - 'triage' - if: |- - ${{ needs.triage.outputs.selected_labels != '' }} - permissions: - contents: 'read' - issues: 'write' - pull-requests: 'write' - steps: - - name: 'Mint identity token' - id: 'mint_identity_token' - if: |- - ${{ vars.APP_ID }} - uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 - with: - app-id: '${{ vars.APP_ID }}' - private-key: '${{ secrets.APP_PRIVATE_KEY }}' - permission-contents: 'read' - permission-issues: 'write' - permission-pull-requests: 'write' - - - name: 'Apply labels' - env: - ISSUE_NUMBER: '${{ github.event.issue.number }}' - AVAILABLE_LABELS: '${{ needs.triage.outputs.available_labels }}' - SELECTED_LABELS: '${{ needs.triage.outputs.selected_labels }}' - uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 - with: - # Use the provided token so that the "gemini-cli" is the actor in the - # log for what changed the labels. - github-token: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' - script: |- - // Parse the available labels - const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',') - .map((label) => label.trim()) - .sort() - - // Parse the label as a CSV, reject invalid ones - we do this just - // in case someone was able to prompt inject malicious labels. - const selectedLabels = (process.env.SELECTED_LABELS || '').split(',') - .map((label) => label.trim()) - .filter((label) => availableLabels.includes(label)) - .sort() - - // Set the labels - const issueNumber = process.env.ISSUE_NUMBER; - if (selectedLabels && selectedLabels.length > 0) { - await github.rest.issues.setLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: selectedLabels, - }); - core.info(`Successfully set labels: ${selectedLabels.join(',')}`); - } else { - core.info(`Failed to determine labels to set. There may not be enough information in the issue or pull request.`) - } diff --git a/.gitignore b/.gitignore index c04e11c..fecf79e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,13 @@ /target Cargo.lock /.ai-agents -/.loctree +*.txt +/*.py + +# .vibecrafted: track GUIDELINES.md + the plans/reports symlinks (which point +# at the per-day artifact root in $HOME). Ignore everything else (tmp scratch, +# generated prompts, ad-hoc files). +/.vibecrafted/* +!/.vibecrafted/GUIDELINES.md +!/.vibecrafted/plans +!/.vibecrafted/reports diff --git a/.vibecrafted/GUIDELINES.md b/.vibecrafted/GUIDELINES.md new file mode 100644 index 0000000..a401d21 --- /dev/null +++ b/.vibecrafted/GUIDELINES.md @@ -0,0 +1,219 @@ +# rust-mux β€” VetCoders GUIDELINES + +> Per-repo, agent-agnostic instructions. Same rules for Claude, Codex, Gemini, Junie, Qwen. +> Global doctrine in `~/.claude/CLAUDE.md` (and equivalents) still applies; this file +> overrides/extends only where this repo has its own contract. + +## Identity + +- **Crate:** `rust-mux` v0.4.0 Β· edition 2024 Β· MIT OR Apache-2.0 +- **Org:** github.com/Loctree/rust-mux +- **Role:** library-first MCP transport multiplexer (`run_mux_server` / `spawn_mux_server` / `MuxHandle`) plus two CLI binaries (`rust-mux`, `rust-mux-proxy`). +- **Active runtime line:** `src/runtime/` (modular). The `src/runtime.rs` monolith is gone (commit `2638441`). Do **not** reintroduce it. `mux-000 runtime duplication` is resolved at HEAD; if a plan still references `src/runtime.rs`, the plan is stale. +- **Active wizard line:** `src/wizard/` (modular). `src/wizard_legacy.rs` and `src/runtime_legacy.rs` are deleted (commit `4b896b3`). Same rule. + +## Canonical command surface + +Use the `Makefile`. Do **not** invent equivalent ad-hoc cargo invocations; the operator and CI key off these names. + +| Target | Meaning | +|---|---| +| `make gates` | **Required green before any commit:** `fmt-check + clippy + test` (all features). | +| `make test-full` | `gates` + `--ignored` transport tests (`mux_transport_roundtrip_with_loctree_mcp` needs local `loctree-mcp` running). | +| `make check` | `cargo check --all-targets --all-features` | +| `make wizard` / `make wizard-dry-run` | Three-step TUI: services β†’ clients β†’ save (with safe vs `[DANGER]` paths). | +| `make run` / `make run-tray` | Run mux daemon for `SERVICE` from `CONFIG` (default `~/.codex/mcp-mux.toml`). | +| `make proxy` | Run `rust-mux-proxy` against `SOCKET`. | +| `make health` / `make daemon-status` / `make dashboard` | Single-service health, multi-service daemon status, tray dashboard. | + +Override variables on the command line: `make run SERVICE=brave-search CONFIG=~/.codex/mcp.json`. + +CI mirrors `make gates` (`.github/workflows/ci.yml`) with `--no-default-features` so tray drops out cleanly. Anything you add must keep CI green in both feature configurations. + +## Quality gates β€” non-negotiable + +Before every commit: + +```bash +make gates # fmt-check + clippy -D warnings + test --all-features +``` + +For changes touching `runtime/` transport, also run `make test-full` locally (requires `loctree-mcp` binary on PATH). + +Suppression policy: do **not** add new `#[allow(...)]`, `// nosemgrep`, `clippy::*` allows, or `#[cfg(test)] dead_code` islands without a one-line `Why:` comment naming the constraint. Existing forgotten silencers are fair game for `vc-prune`. + +## Living Tree convention + +This repo runs **parallel agents on a shared worktree** (Claude, Codex, Gemini all touching `src/` simultaneously). Concurrent edits are the rule, not the exception. + +- Re-read files before editing if more than a few minutes passed since last `slice` / `Read`. +- Before any non-trivial edit window, check `loctree-mcp doctor()` (or atlas `receipt`) to detect a concurrent rescan; if fingerprint moved, call `context(fresh: true)` again. +- Do **not** revert another agent's change unless explicitly asked; if it conflicts with your work, reconcile or report β€” never silently overwrite. +- Commit in 5–6 file packs when shape allows. `git commit --only ` is **not atomic** vs. parallel commits β€” your message can land under another agent's envelope. Don't fight it; document in the report and move on. +- `~/.vibecrafted/artifacts/Loctree/rust-mux//{plans,reports}` is auto-rotated daily; the in-repo `.vibecrafted/{plans,reports}` are symlinks to today's directory. Symlink target moving on a date roll is **not** a code change β€” leave the diff if it appears in `git status`. +- `.vibecrafted/tmp/` is scratch. Anything under `.vibecrafted/` is gitignored (per `/.vibecrafted` rule). + +## Structural map (for planning, not for memorization) + +Always run `loctree-mcp slice ` before editing a hub. Current top hubs (importer counts, drift fast): + +| File | Importers | Role | +|---|---|---| +| `src/config.rs` | 13 | `MuxConfig`, `ServerConfig`, `ResolvedParams`, `CliOptions` trait | +| `src/state.rs` | 11 | `MuxState`, `StatusSnapshot`, error/id helpers | +| `src/scan.rs` | 9 | Host discovery, rewire (1388 LOC β€” split candidate when touched) | +| `src/multi.rs` | 6 | Multi-service supervisor | +| `src/runtime/mod.rs` | 5 | `run_mux`, `run_mux_internal`, entrypoints | + +Known structural debt β€” **triage, don't chop:** + +- **`src/common.rs` is a half-finished extraction.** Twins exist in `scan.rs` for `HostKind`, `HostFormat`, `as_label`, `display_name`. The `common.rs` copies have 0 importers; the `scan.rs` copies are live. Consolidation is desirable, but `common.rs` is a forgotten gem β€” preserve the intent, surface it as a `vc-prune`/`vc-marbles` finding, do not delete in passing. +- **`print_status_table`, `DaemonStatus`, `check_health`, `HealthStatus` twins** between `lib.rs` ↔ `runtime/status.rs` and `wizard/types.rs` ↔ `state.rs`. Same triage rule. +- **One cycle:** `src/multi.rs ↔ src/state.rs` (length 2). Acceptable while the supervisor needs `MuxState`; flag if you add a third hop. + +## Wizard / config doctrine + +The wizard runs a 5-step flow (see `docs/WIZARD.md` for screens): + +``` +DiscoverySources β†’ ServerReview β†’ StrategyChoice β†’ SummaryConfirm β†’ ResultAndTray +``` + +**Source of truth is client config files, not running processes.** STEP 1 +scans `default_sources()` (`~/.claude.json`, `~/.codex/config.toml`, +`~/.gemini/settings.json`, `~/.junie/`, `~/.ai/`, `~/.agents/`, plus +legacy editor hosts) and lets the operator add custom paths. ps-scan is +demoted to enrichment-only β€” it stamps PIDs on matching entries and +surfaces ps-only orphans, never drives discovery. + +Three strategies on STEP 3, do **not** merge them: + +- **Unified β†’ `mux_gen::build_mux_outputs`.** Writes + `~/.config/mux/{config.toml, mcp.json, mcp.toml}` with every selected + server, deduplicated. Recommended onboarding flow. Never touches + host-side AI client configs. STEP 5 prints per-client startup + snippets (`claude --strict-mcp-config …`, `junie --mcp-location …`, + `gemini mcp add …`, plus a Codex merge note). +- **Per-client β†’ `mux_gen::build_per_client_outputs`.** Writes one mux + config per originating client kind in that client's native format + (`claude.json`, `codex.toml`, `junie.json`, …) under `~/.config/mux/`. + Daemon `config.toml` still merged across every selected source so + the running mux can reach every upstream server. Use when different + agents should see different stacks. +- **`[DANGER]` Auto-rewire β†’ `danger::plan_danger_rewrite` + `execute_plan`.** + Backup-first, preview-first JSON/TOML rewrite of `.claude/`, + `.codex/`, `.junie/` host configs with rollback. Always creates + timestamped `..bak`, always shows the preview, + always requires explicit `CONFIRM` on cooked stdin. Sources flagged + `eligible_for_danger = false` (currently Gemini's `settings.json` β€” + no verified strict-config flag) are listed but skipped. + +Never silently rewrite a host config from a non-danger strategy. Never +skip the backup or the `CONFIRM` prompt on the danger path. Never +reintroduce the legacy `[SAFE GEN] / [MUX ONLY] / [CLIPBOARD]` overlay +β€” STEP 4's `Confirm / Back / Cancel` is the canonical action chooser. + +Reference: `docs/WIZARD.md`, `docs/vc-agents-client-discovery-plan.md`. + +## Tray feature dependency risks + +The `tray` Cargo feature pulls `tray-icon β†’ muda (gtk feature) β†’ gtk 0.18 β†’ glib 0.18`. As of 2026-05-06 cargo audit reports: + +- 1 unsoundness: glib 0.18.5 β€” **RUSTSEC-2024-0429** (`VariantStrIter::{next, nth, next_back, nth_back, last}` UB; patched in glib >= 0.20.0). +- 8 unmaintained: atk, atk-sys, gdk, gdk-sys, gtk, gtk-sys, gtk3-macros, proc-macro-error (RUSTSEC-2024-0412..0420 + RUSTSEC-2024-0370 β€” gtk-rs GTK3 bindings archived; migration path is gtk4-rs). + +Mitigation in place: CI builds with `--no-default-features`, so library and proxy binary consumers never link the unsound code; only desktop/tray users do. + +Active code path: rust-mux's tray code does **not** call `glib::VariantStrIter` directly. The unsound function is reachable only via tray-icon's menu construction code. + +Action: track tray-icon for a release that bumps the chain to glib >= 0.20 (or migrates muda to gtk4-rs). Bump in lockstep when available. + +Operator-facing impact: none today; no CVE, no exploit path. The advisory is a "correctness debt to clear" rather than a security incident. + +## API surface (library users) + +The public library surface is what's re-exported from `src/lib.rs`. Keep it stable across patch versions: + +```rust +use rust_mux::{ + MuxConfig, MuxHandle, ResolvedParams, CliOptions, + run_mux_server, spawn_mux_server, run_mux_with_shutdown, check_health, +}; +``` + +Internal modules (`runtime::client`, `runtime::server`, `wizard::services`, ...) are private; if a consumer needs a symbol from there, promote it through `lib.rs` deliberately β€” do not leak the path. + +Feature gating: +- default: `["tray", "cli"]` +- `cli` β†’ wizard, scan, binaries (clap, ratatui, crossterm, tracing-subscriber) +- `tray` β†’ tray-icon + image (GUI deps; CI builds with `--no-default-features` to keep headless green) +- Library-only consumers should depend with `default-features = false`. + +## Paths (v0.4.0 canonical) + +- Sockets: `~/.rmcp-servers/rust-mux/sockets/.sock` +- Status files: `~/.rust-mux/status/.json` (per-service) or `~/.rmcp-servers/rust-mux/status.json` (combined) +- Mux config (safe wizard): `~/.config/mux/{mcp.json, mcp.toml, config.toml}` +- Default service config: `~/.codex/mcp-mux.toml` +- Detection still matches **legacy `rmcp_mux` patterns** (transition compat); do not strip the legacy regex without a release note. + +## .env hygiene + +Repo currently ships zero `.env*` files. If you ever need one locally: +- Add the exact filename to `.gitignore` (the existing `/.vibecrafted` + `/*.py` rules are intentional; widening to `.env*` is a normal addition). +- Never commit even an example with real secrets. +- Pre-commit blocks accidental `.env` commits; if a hook fires, fix the cause, don't bypass. + +## Commit convention + +- Title prefix: `[/] ` (e.g. `[claude/vc-implement] add heartbeat config plumbing`). +- Body: bulleted list of changes if non-trivial. Empty bodies are not allowed for multi-file packs. +- **AGENT FAIRNESS.** Trailer authorship goes to the agent that **actually wrote the code**: + ``` + Authored-By: + ``` + where `` ∈ {`claude`, `codex`, `gemini`, `junie`, `qwen`}. Multi-agent collaboration β†’ multiple `Authored-By` lines. +- **Forbidden trailers in this repo:** + - `Co-Authored-By: Claude Opus … ` and any vendor-default footer. + - `Co-Authored-By: Maciej/Klaudiusz/` β€” no personal sigs in commits. + - Coordinator agents do **not** add their signature to other agents' work. +- Footer ends with the canonical brand line **only when a sigblock is needed** (file headers, release notes, public artifacts): + ``` + πš…πš’πš‹πšŽπšŒπš›πšŠπšπšπšŽπš. with AI Agents by VetCoders (c)2024-2026 LibraxisAI + ``` + Note the trailing dot after `πš…πš’πš‹πšŽπšŒπš›πšŠπšπšπšŽπš`, the `2024-2026` range, and `LibraxisAI` (not `VetCoders`). + +`Cargo.toml` `authors = [...]` is package metadata (PEP 621 / npm-style), not a sig β€” leave it alone. + +## Init discipline + +Every session on this repo starts with: +1. Read `~/.claude/Klaudiusz/kronika_*.md` (or equivalent agent chronicle). +2. `/vc-init` (no-op): perception via `loctree-mcp context()`, intentions via `aicx_intents project=rust-mux`, ground truth via `repo-full`. +3. **Then** touch code. + +Skipping init in this repo is how parallel agents step on each other. Don't. + +## Anti-patterns specific to this repo + +- Reintroducing `src/runtime.rs` monolith because a plan referenced it. The plan is stale. +- Reintroducing `src/runtime_legacy.rs` / `src/wizard_legacy.rs`. They are deleted on purpose. +- Reintroducing the old 4-step wizard (`ServerSelection β†’ ClientSelection β†’ Confirmation β†’ HealthCheck`) or the old `ConfirmChoice` overlay (`[SAFE GEN]/[MUX ONLY]/[CLIPBOARD]/[DANGER]`). The 5-step flow with `Strategy { Unified, PerClient, AutoRewire }` is canonical. +- Reintroducing the `MCP_PATTERNS` whitelist as primary discovery. ps-scan is enrichment-only; configuration discovery runs through `scan::scan_hosts()`. +- Silently dropping the `legacy rmcp_mux` detection regex. +- Merging `src/mux_gen.rs` and `src/danger.rs` into one "wizard config writer" β€” the strategy split is the security model. +- Bumping `Cargo.toml` version without a matching `CHANGELOG.md` section. +- Editing `AI_README.md` without also re-running structure check (`loctree-mcp focus src/`) β€” it lies the moment the tree changes. +- Using `socat` instead of `rust-mux-proxy` for host STDIO bridges in examples. +- Adding `#[allow(dead_code)]` to silence a clippy after a refactor instead of either consolidating the twin or surfacing it as a finding. + +## Notes for AI agents + +- Comments and docstrings: English. Polish is reserved for operator-facing reports under `.vibecrafted/reports/`. +- `.ai-agents/**` and `*.txt` are scratch β€” do not commit. +- `AGENTS.md` (root, if it appears) is deprecated; ignore it. This file is the canonical per-repo source. +- When you find drift between this file and the code, **the code wins** β€” open a follow-up to update GUIDELINES, do not bend the code to match stale guidelines. + +--- + +_πš…πš’πš‹πšŽπšŒπš›πšŠπšπšπšŽπš. with AI Agents by VetCoders (c)2024-2026 LibraxisAI_ diff --git a/.vibecrafted/plans b/.vibecrafted/plans new file mode 120000 index 0000000..771027e --- /dev/null +++ b/.vibecrafted/plans @@ -0,0 +1 @@ +/Users/polyversai/.vibecrafted/artifacts/Loctree/rust-mux/2026_0506/plans \ No newline at end of file diff --git a/.vibecrafted/reports b/.vibecrafted/reports new file mode 120000 index 0000000..3ea05d7 --- /dev/null +++ b/.vibecrafted/reports @@ -0,0 +1 @@ +/Users/polyversai/.vibecrafted/artifacts/Loctree/rust-mux/2026_0506/reports \ No newline at end of file diff --git a/AI_README.md b/AI_README.md index 1568ba9..f119593 100644 --- a/AI_README.md +++ b/AI_README.md @@ -1,36 +1,39 @@ -# rmcp-mux – AI-facing Overview +# rust-mux – AI-facing Overview -> **Version:** 0.3.0 -> **Last updated:** 2025-12-04 +> **Version:** 0.4.0 +> **Last updated:** 2026-05-05 +> **Per-repo doctrine:** see `.vibecrafted/GUIDELINES.md` (canonical, agent-agnostic) -This document provides a concise technical overview for AI agents working with the rmcp-mux codebase. +This document provides a concise technical overview for AI agents working with the rust-mux codebase. ## Purpose -**Library-first MCP multiplexer** – share a single MCP server process across many hosts via Unix socket. +**Library-first MCP multiplexer** β€” share a single MCP server process across many hosts via Unix socket. Two usage modes: -1. **As a library** – embed in Rust applications, run multiple MCP services in one process -2. **As a CLI** – standalone daemon with wizard, scan, and rewire commands +1. **As a library** β€” embed in Rust applications, run multiple MCP services in one process. +2. **As a CLI** β€” standalone daemon plus wizard, scan, rewire, health, dashboard, and proxy commands. Core features: - JSON-RPC ID rewriting per client - `initialize` request caching and fan-out - Request limits, timeouts, and size guards -- Child process restart with exponential backoff -- Status snapshots for UI/automation +- Child process restart with exponential backoff and capped max-restarts +- Heartbeat-based child health checks with explicit timeout +- Status snapshots (per-service JSON) for UI/automation +- Multi-service daemon with a status query socket and a tray dashboard ## Quick Start ### Library Usage (Recommended) ```rust -use rmcp_mux::{MuxConfig, run_mux_server}; +use rust_mux::{MuxConfig, run_mux_server}; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = MuxConfig::new("/tmp/mcp.sock", "npx") - .with_args(vec!["-y".into(), "@mcp/server-memory".into()]) + .with_args(vec!["-y".into(), "@modelcontextprotocol/server-memory".into()]) .with_max_clients(10); run_mux_server(config).await } @@ -39,48 +42,58 @@ async fn main() -> anyhow::Result<()> { ### CLI Usage ```bash -# Build +# Build (default features include cli + tray) cargo build --release -# Run mux daemon -./target/release/rmcp-mux \ - --socket ~/.rmcp_servers/rmcp-mux/sockets/memory.sock \ +# Run mux daemon for a single service +./target/release/rust-mux \ + --socket ~/.rmcp-servers/rust-mux/sockets/memory.sock \ --cmd npx -- @modelcontextprotocol/server-memory \ --max-active-clients 5 \ - --status-file ~/.rmcp_servers/rmcp-mux/status.json + --status-file ~/.rmcp-servers/rust-mux/status.json -# Host side: use bundled proxy -rmcp-mux-proxy --socket ~/.rmcp_servers/rmcp-mux/sockets/memory.sock +# Host side: use bundled proxy (preferred over socat) +rust-mux-proxy --socket ~/.rmcp-servers/rust-mux/sockets/memory.sock ``` -## Project Structure (v0.3.0) +The canonical command surface is the `Makefile`. Use `make gates` before every commit; see `.vibecrafted/GUIDELINES.md` for the full target list. + +## Project Structure (v0.4.0) ``` src/ -β”œβ”€β”€ lib.rs # Library entry point, public API -β”œβ”€β”€ config.rs # Config, ServerConfig, ResolvedParams, CliOptions trait -β”œβ”€β”€ state.rs # MuxState, StatusSnapshot, error_response, set_id -β”œβ”€β”€ scan.rs # Host discovery, rewiring (feature: cli) -β”œβ”€β”€ tray.rs # Tray icon (feature: tray) +β”œβ”€β”€ lib.rs # Library entry point, public API re-exports +β”œβ”€β”€ main.rs # CLI entry (feature: cli) +β”œβ”€β”€ config.rs # MuxConfig, ServerConfig, ResolvedParams, CliOptions trait +β”œβ”€β”€ state.rs # MuxState, StatusSnapshot, DaemonStatus, error helpers +β”œβ”€β”€ common.rs # Shared host-format helpers (extraction in progress; see GUIDELINES) +β”œβ”€β”€ scan.rs # Host discovery + rewire (feature: cli) +β”œβ”€β”€ mux_gen.rs # Safe wizard path: emit ~/.config/mux/{mcp.json, mcp.toml, config.toml} +β”œβ”€β”€ danger.rs # [DANGER] wizard path: backup-first JSON/TOML rewrite of host configs with rollback +β”œβ”€β”€ multi.rs # Multi-service supervisor +β”œβ”€β”€ multi_tui.rs # Multi-service TUI +β”œβ”€β”€ tray.rs # Tray icon (feature: tray) +β”œβ”€β”€ tray_dashboard.rs # Tray dashboard for multi-service status (feature: tray) β”œβ”€β”€ bin/ -β”‚ β”œβ”€β”€ rmcp_mux.rs # CLI binary (feature: cli) -β”‚ └── rmcp_mux_proxy.rs # STDIO↔socket proxy (feature: cli) -β”œβ”€β”€ runtime/ # Mux daemon core -β”‚ β”œβ”€β”€ mod.rs # run_mux, run_mux_internal, health_check -β”‚ β”œβ”€β”€ types.rs # ServerEvent, MAX_QUEUE, MAX_PENDING -β”‚ β”œβ”€β”€ client.rs # handle_client, handle_client_message -β”‚ β”œβ”€β”€ server.rs # server_manager, handle_server_events -β”‚ β”œβ”€β”€ proxy.rs # run_proxy (STDIO) -β”‚ β”œβ”€β”€ status.rs # write_status_file, spawn_status_writer -β”‚ └── tests.rs # All runtime tests -└── wizard/ # Three-step TUI wizard (feature: cli) - β”œβ”€β”€ mod.rs # run_wizard, run_tui - β”œβ”€β”€ types.rs # WizardStep, ServiceEntry, ClientEntry, FormState - β”œβ”€β”€ services.rs # load_all_services, detect_running_mcp_servers - β”œβ”€β”€ clients.rs # detect_clients - β”œβ”€β”€ ui.rs # draw_ui, draw_service_list, draw_client_list - β”œβ”€β”€ keys.rs # handle_key, sync_form_to_service - └── persist.rs # persist_all, rewire_selected_clients +β”‚ β”œβ”€β”€ rust-mux.rs # CLI binary (feature: cli) +β”‚ └── rust-mux-proxy.rs # STDIO↔socket proxy (feature: cli) +β”œβ”€β”€ runtime/ # Mux daemon core (modular β€” src/runtime.rs is gone, do not revive) +β”‚ β”œβ”€β”€ mod.rs # run_mux, run_mux_internal, entry points +β”‚ β”œβ”€β”€ types.rs # ServerEvent, MAX_QUEUE, MAX_PENDING +β”‚ β”œβ”€β”€ client.rs # handle_client, handle_client_message +β”‚ β”œβ”€β”€ server.rs # server_manager (child lifecycle, restart backoff) +β”‚ β”œβ”€β”€ proxy.rs # run_proxy (STDIO bridge) +β”‚ β”œβ”€β”€ heartbeat.rs # Heartbeat loop, child health check +β”‚ β”œβ”€β”€ status.rs # write_status_file, spawn_status_writer, daemon status socket +β”‚ └── tests.rs # Runtime tests (incl. ignored mux_transport_roundtrip_with_loctree_mcp) +└── wizard/ # Three-step TUI wizard (feature: cli) + β”œβ”€β”€ mod.rs # run_wizard, run_tui (safe + [DANGER] paths) + β”œβ”€β”€ types.rs # WizardStep, ServiceEntry, ClientEntry, FormState + β”œβ”€β”€ services.rs # load_all_services, detect_running_mcp_servers + β”œβ”€β”€ clients.rs # detect_clients (Codex, Cursor, VSCode, Claude, JetBrains, Gemini, Junie, ~/.ai, ~/.agents) + β”œβ”€β”€ ui.rs # draw_ui, draw_service_list, draw_client_list + β”œβ”€β”€ keys.rs # handle_key, sync_form_to_service + └── persist.rs # persist_all, rewire_selected_clients ``` ## Library API @@ -97,10 +110,10 @@ src/ ### Entry Points ```rust -// Blocking - runs until Ctrl+C +// Blocking β€” runs until Ctrl+C run_mux_server(config: MuxConfig) -> Result<()> -// Non-blocking - returns handle for control +// Non-blocking β€” returns handle for control spawn_mux_server(config: MuxConfig) -> Result // External shutdown control @@ -114,12 +127,12 @@ check_health(socket: impl AsRef) -> Result<()> ```rust MuxConfig::new(socket, cmd) - .with_args(vec![...]) // Command arguments - .with_max_clients(10) // Max concurrent clients - .with_service_name("my-svc") // For logging/status + .with_args(vec![...]) // Command arguments + .with_max_clients(10) // Max concurrent clients + .with_service_name("my-svc") // For logging/status .with_request_timeout(Duration::from_secs(60)) - .with_lazy_start(true) // Spawn on first request - .with_status_file("/path") // JSON snapshots + .with_lazy_start(true) // Spawn child on first request + .with_status_file("/path") // JSON status snapshots ``` ### MuxHandle Methods @@ -134,45 +147,67 @@ MuxConfig::new(socket, cmd) | Command | Purpose | |---------|---------| -| (default) | Run mux daemon | -| `wizard` | Three-step TUI: servers β†’ clients β†’ save | +| (default) | Run mux daemon for a single service | +| `wizard` | Three-step TUI: services β†’ clients β†’ save (safe vs `[DANGER]` paths) | | `scan` | Discover hosts, generate manifest/snippets | -| `rewire` | Update host config to use proxy | -| `status` | Check if host is rewired | -| `health` | Verify socket reachability | -| `proxy` | STDIO↔socket proxy | +| `rewire` | Update host config to use proxy (creates `.bak`; supports `--dry-run`) | +| `status` | Check whether a host is rewired | +| `health` | Verify socket reachability for a service | +| `daemon-status` | Query running multi-service daemon status via Unix socket | +| `dashboard` | Tray dashboard for multi-service status (feature: tray) | +| `proxy` | STDIO↔socket proxy (also exposed as the `rust-mux-proxy` binary) | -## Config (JSON/YAML/TOML) +## Config (JSON / YAML / TOML) -Default path: `~/.codex/mcp.json` (override `--config`, pick `--service` key under `servers.`). +Default service config: `~/.codex/mcp-mux.toml` (override with `--config`, pick `--service` key under `servers.`). **Fields per service:** -- `socket`, `cmd`, `args` – required -- `max_active_clients` – default 5 -- `lazy_start` – default false -- `max_request_bytes` – default 1_048_576 -- `request_timeout_ms` – default 30_000 -- `restart_backoff_ms` – default 1_000 -- `restart_backoff_max_ms` – default 30_000 -- `max_restarts` – default 5 (0 = unlimited) +- `socket`, `cmd`, `args` β€” required +- `max_active_clients` β€” default 5 +- `lazy_start` β€” default false +- `max_request_bytes` β€” default 1_048_576 +- `request_timeout_ms` β€” default 30_000 +- `restart_backoff_ms` β€” default 1_000 +- `restart_backoff_max_ms` β€” default 30_000 +- `max_restarts` β€” default 5 (0 = unlimited) +- `heartbeat_enabled` β€” per-server heartbeat toggle (v0.4.0) +- `heartbeat_interval_ms` β€” default 30_000 (v0.4.0) +- `heartbeat_timeout_ms` β€” timeout before marking child unhealthy (v0.4.0) - `tray`, `service_name`, `log_level` -- `status_file` – JSON snapshots for UI/automation +- `status_file` β€” atomic JSON snapshots for UI/automation -## Three-Step Wizard +## Five-Step Wizard ```bash -rmcp-mux wizard --config ~/.codex/mcp-mux.toml +rust-mux wizard --config ~/.codex/mcp-mux.toml +# or +make wizard ``` -1. **Server Detection** – scans `ps` for MCP processes, loads config, toggles with `Space` -2. **Client Detection** – finds Codex/Cursor/VSCode/Claude/JetBrains configs, shows rewire status -3. **Confirmation** – save options: Save All, Mux Only, Clipboard, Back, Exit +1. **DiscoverySources** β€” toggle which client config files to scan + (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, + `~/.junie/`, `~/.ai/`, `~/.agents/`, plus legacy editor hosts), with + a custom-path text input (`i`) for additional files. +2. **ServerReview** β€” read-only tree of discovered MCP servers grouped + by originating client. Identical entries are deduplicated; conflicts + surface with deterministic `-from-` rename. +3. **StrategyChoice** β€” pick how to use the discovery: + - **Unified** β€” one mux config under `~/.config/mux/{config.toml, mcp.json, mcp.toml}` with every selected server. Recommended. + - **Per-client** β€” one mux config per originating client kind, in that client's native format (`claude.json`, `codex.toml`, `junie.json`, ...). + - **`[DANGER]` Auto-rewire** β€” backup-first preview-first rewrite of the user's existing client configs to route through `rust-mux-proxy`, with rollback commands. +4. **SummaryConfirm** β€” preview of what will be written and where, then `Confirm` / `Back` / `Cancel`. +5. **ResultAndTray** β€” show what was written with per-client startup snippets, then offer to start a tray daemon now (spawns `rust-mux --tray --config ` detached). + +Navigation: `Up/Down` choose, `Space` toggle, `Enter` / `n` next step, `p` previous, `q` quit, `i` open custom-path input on STEP 1. + +Source of truth is **client configs**, not running processes. ps-scan is used as enrichment to stamp PIDs and surface running orphans, never as the discovery driver. -Navigation: `n` next, `p` previous, `Space` toggle, `Tab` switch panel, `q` quit. +Detail: `docs/WIZARD.md`, `docs/vc-agents-client-discovery-plan.md`. ## Status Snapshots Written atomically to `status_file` on every state change: + ```json { "service_name": "memory", @@ -183,88 +218,90 @@ Written atomically to `status_file` on every state change: "pending_requests": 0, "queue_depth": 0, "child_pid": 12345, - "cached_initialize": true + "cached_initialize": true, + "heartbeat_status": "Healthy", + "last_heartbeat_ms": 12345 } ``` -## Testing +The multi-service daemon additionally serves a status socket; query it via `rust-mux daemon-status` (or `make daemon-status`). + +## Testing & Quality Gates + +The canonical surface is the `Makefile`: ```bash -# Full suite (40 tests) -cargo test +make gates # fmt-check + clippy -D warnings + test --all-features (REQUIRED before commit) +make test-full # gates + ignored transport tests (needs local loctree-mcp on PATH) +make check # cargo check --all-targets --all-features +``` -# Without tray feature (CI/headless) -cargo test --no-default-features +Direct cargo equivalents: -# Linting +```bash +cargo fmt -- --check cargo clippy --all-targets --all-features -- -D warnings - -# Coverage -cargo tarpaulin --all-targets --no-default-features --out Lcov +cargo test --all-targets --all-features # 83 passed, 1 ignored at HEAD +cargo test --all-targets --all-features -- --ignored # transport roundtrip via loctree-mcp +cargo clippy --all-targets --no-default-features -- -D warnings # CI configuration ``` +CI (`.github/workflows/ci.yml`) runs with `--no-default-features` (tray off) so headless boxes stay green. + ## Key Symbols for Navigation | Symbol | Location | Purpose | |--------|----------|---------| -| `MuxConfig` | lib.rs | Builder for programmatic configuration | -| `MuxHandle` | lib.rs | Lifecycle control (shutdown, wait, is_running) | -| `run_mux_server` | lib.rs | Blocking server entry point | -| `spawn_mux_server` | lib.rs | Non-blocking spawn returning MuxHandle | -| `run_mux_with_shutdown` | lib.rs | External CancellationToken support | -| `check_health` | lib.rs | Socket health check | -| `CliOptions` | config.rs | Trait for generic CLI parameter handling | -| `ResolvedParams` | config.rs | Merged CLI + config parameters | -| `MuxState` | state.rs | Runtime state (clients, pending, cache) | -| `StatusSnapshot` | state.rs | JSON status output | -| `run_mux` | runtime/mod.rs | Main mux loop (with internal shutdown) | -| `run_mux_internal` | runtime/mod.rs | Main mux loop (external shutdown) | -| `server_manager` | runtime/server.rs | Child process lifecycle | -| `handle_client` | runtime/client.rs | Client connection handler | -| `run_wizard` | wizard/mod.rs | TUI entry point (feature: cli) | -| `WizardStep` | wizard/types.rs | Step enum (Server/Client/Confirmation) | -| `discover_hosts` | scan.rs | Find host config files (feature: cli) | +| `MuxConfig` | `lib.rs` (re-export from `config.rs`) | Builder for programmatic configuration | +| `MuxHandle` | `lib.rs` | Lifecycle control (shutdown, wait, is_running) | +| `run_mux_server` | `lib.rs` | Blocking server entry point | +| `spawn_mux_server` | `lib.rs` | Non-blocking spawn returning MuxHandle | +| `run_mux_with_shutdown` | `lib.rs` | External CancellationToken support | +| `check_health` | `lib.rs` | Socket health check | +| `CliOptions` | `config.rs` | Trait for generic CLI parameter handling | +| `ResolvedParams` | `config.rs` | Merged CLI + config parameters | +| `MuxState` | `state.rs` | Runtime state (clients, pending, cache) | +| `StatusSnapshot` / `DaemonStatus` | `state.rs` | JSON status output | +| `run_mux` / `run_mux_internal` | `runtime/mod.rs` | Main mux loop (internal vs external shutdown) | +| `server_manager` | `runtime/server.rs` | Child process lifecycle + restart backoff | +| `handle_client` | `runtime/client.rs` | Per-client connection handler | +| `heartbeat_loop` | `runtime/heartbeat.rs` | Child health probe | +| `run_proxy` | `runtime/proxy.rs` | STDIO↔socket bridge (also `rust-mux-proxy` binary) | +| `run_wizard` | `wizard/mod.rs` | TUI entry point (feature: cli) | +| `WizardStep` | `wizard/types.rs` | Step enum (Server / Client / Confirmation) | +| `discover_hosts` | `scan.rs` | Find host config files (feature: cli) | +| `emit_mux_config` | `mux_gen.rs` | Safe wizard path: write `~/.config/mux/*` | +| `apply_with_backup` | `danger.rs` | `[DANGER]` wizard path: backup β†’ preview β†’ rewrite β†’ rollback | ## Notes for AI Agents -1. **Library-first architecture:** Use `MuxConfig` + `spawn_mux_server` for embedding. CLI is feature-gated. - -2. **Feature gating:** - - `cli` feature: wizard, scan, binaries (clap, ratatui, crossterm) - - `tray` feature: system tray icon (tray-icon, image) - - Build with `--no-default-features` for library-only. +1. **Read `.vibecrafted/GUIDELINES.md` first.** It is the canonical per-repo doctrine (Living Tree convention, commit format, AGENT FAIRNESS, anti-patterns). This file is reference material; GUIDELINES is the contract. -3. **Naming convention:** - - Package name: `rmcp-mux` (crates.io, Cargo.toml) - - Library name: `rmcp_mux` (Rust identifier, `use rmcp_mux::*`) - - Binary names: `rmcp-mux`, `rmcp-mux-proxy` +2. **Library-first architecture.** Use `MuxConfig` + `spawn_mux_server` for embedding. CLI is feature-gated. -4. **Single child model:** One MCP server per socket. Multiple services = multiple MuxConfig instances. +3. **Feature gating:** + - `cli` β†’ wizard, scan, binaries (`clap`, `ratatui`, `crossterm`, `tracing-subscriber`). + - `tray` β†’ system tray icon (`tray-icon`, `image`). + - For library-only consumers, depend with `default-features = false`. -5. **Initialize caching:** First `initialize` is cached in `MuxState.cached_initialize`. Later clients get cached response via `init_waiting`. +4. **Naming convention:** + - Package name: `rust-mux` (crates.io, `Cargo.toml`). + - Library name: `rust_mux` (Rust identifier, `use rust_mux::*`). + - Binary names: `rust-mux`, `rust-mux-proxy`. + - Detection still recognises legacy `rmcp_mux` patterns; do not strip without a release note. -6. **Error handling:** Use `anyhow::Result` and `.with_context()` for all fallible operations. +5. **Single child model.** One MCP server per socket. Multiple services = multiple `MuxConfig` instances or multi-service daemon mode. -7. **Tests:** Colocated in each module as `#[cfg(test)] mod tests`. Use `tempfile::tempdir()` for filesystem tests. +6. **Initialize caching.** First `initialize` is cached in `MuxState.cached_initialize`; later clients get the cached response via `init_waiting`. -8. **Workspace:** `.ai-agents/` is AI scratch space. Keep helper files there, document in `AI_GUIDELINES.md`. +7. **Error handling.** Use `anyhow::Result` and `.with_context()` for fallible operations. Avoid panics in the runtime path. -9. **Code style:** - - Imports: std β†’ external crates β†’ crate-local - - English comments only - - Run `cargo fmt` before committing +8. **Modular runtime is final.** `src/runtime.rs` (monolith) and `src/runtime_legacy.rs` / `src/wizard_legacy.rs` are deleted. If a plan references them, the plan is stale β€” verify against `loctree-mcp focus src/runtime` before acting. -## CI Workflow +9. **Comments / docs in English only.** Operator-facing reports under `.vibecrafted/reports/` may be in Polish. -`.github/workflows/ci.yml`: -- `cargo fmt --check` -- `cargo clippy --all-targets --no-default-features -- -D warnings` -- `cargo test --no-default-features` -- `cargo tarpaulin` (coverage) +10. **Tray feature is GUI-bound.** CI builds `--no-default-features` to avoid GUI deps; keep that path green. -## See Also +11. **Prefer `rust-mux-proxy` over `socat`** for host STDIO integration β€” it's the supported bridge. -- [README.md](README.md) – User documentation -- [CHANGELOG.md](CHANGELOG.md) – Version history -- [docs/integration.md](docs/integration.md) – Library integration guide -- [.ai-agents/AI_GUIDELINES.md](.ai-agents/AI_GUIDELINES.md) – Detailed development guidelines +12. **`.ai-agents/**` is scratch space.** Do not commit. Root-level `AGENTS.md` (if present) is deprecated; ignore it. The canonical per-repo source is `.vibecrafted/GUIDELINES.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8276216..584f717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,117 @@ All notable changes to this project will be documented in this file. +## [0.4.1] - 2026-05-06 + +### Added +- **5-step interactive wizard** replacing the legacy 4-step flow: + DiscoverySources β†’ ServerReview β†’ StrategyChoice β†’ SummaryConfirm β†’ ResultAndTray. +- **Three explicit strategies** for using mux: + - **Unified** β€” one ~/.config/mux/{config.toml, mcp.json, mcp.toml}. + - **Per-client** β€” separate mux config per originating client kind in + that client's native format (claude.json, codex.toml, junie.json, ...). + - **[DANGER] Auto-rewire** β€” backup-first preview-first rewrite of + existing client configs to route through `rust-mux-proxy`, with + rollback commands. +- **Custom-path input** on STEP 1 (`i` to enter) for client config files + outside the default discovery list. +- **Tray daemon prompt** on STEP 5: spawn `rust-mux --tray --config + ` detached from the wizard session. +- New helpers: + - `mux_gen::build_per_client_outputs` + `write_per_client_outputs` + + `per_client_instructions` for the Per-client strategy. + - `wizard::services::build_services_from_scans` / + `enrich_running_state` / `load_services_from_custom_path` as the + public discovery surface. + - `config` checked file-read/copy helpers that reject parent + traversal at the filesystem boundary. +- New canonical doc surfaces: `.vibecrafted/GUIDELINES.md` (per-repo, + agent-agnostic doctrine) and a fully rewritten `docs/WIZARD.md`. + +### Changed +- **Discovery is now driven by client config files**, not by ps-scan. + The legacy `MCP_PATTERNS` whitelist is demoted to enrichment-only β€” + it stamps PIDs on matching entries and surfaces ps-only orphans, but + never drives the discovery list. +- **Source-of-truth model** flipped: client configs (Claude / Codex / + Junie / Gemini / ...) are authoritative; running processes are + side-effects. +- **Wizard title** rebranded from `rmcp_mux wizard` to `rust-mux + wizard`. Daemon-status banner and multi-server dashboard header + rebranded in lockstep. +- **Socket path canonicalised** to v0.4.0 + `~/.rmcp-servers/rust-mux/sockets/` everywhere (was a mix of + `~/mcp-sockets/` and the canonical path). +- **AI_README** bumped to 0.4.0 / 2026-05-05; project structure + reflects modular `runtime/` + `wizard/` and the new helper modules. + +### Fixed +- Self-skip dedup bug in the ps-scan: `args.contains("rust-mux") || + args.contains("rust-mux")` was a copy/paste; the second clause now + correctly matches the legacy `rmcp_mux` binary name. +- Per-client strategy output collisions for same-kind sources (Junie + Γ—3, Cursor Γ—2, VSCode Γ—2): same-kind scans now merge before writing + one file per kind. +- Per-client and danger strategies now honour STEP 2 server selection + (previously rescanned the source verbatim). +- Wizard's per-client summary filenames now follow STEP 2 selected + services instead of selected source rows. +- Socket allocation ownership returned to `mux_gen` (services.rs no + longer injects a default socket path that mux_gen would override). +- Removed dead `#[allow(dead_code)]` carry-overs from the C2/C3 + rebuild after consumers landed in the 5-step flow. +- Documentation drift: rmcp_mux references in doc comments, status + banners, and proxy `--socket` help text replaced with rust-mux. + +### Security +- Audited dependency tree for the `tray` feature: 0 vulnerabilities, 1 + unsoundness (glib 0.18.5 RUSTSEC-2024-0429, not on rust-mux's call + graph) and 8 unmaintained advisories (GTK3 stack via tray-icon). + Tracked in `.vibecrafted/GUIDELINES.md` under "Tray feature + dependency risks". CI mitigation: `--no-default-features`. +- `config::checked_read_to_string` and `checked_copy` helpers reject + parent-traversal paths and canonicalise filesystem boundaries; used + by `scan::scan_host_file` and `danger` plan execution. + +### Coverage notes +- `cargo test --all-targets --all-features` baseline went from 83 β†’ 87 + passing (+4 new wizard::services tests, plus persist.rs scenarios). + `mux_transport_roundtrip_with_loctree_mcp` (ignored) passes against + local `loctree-mcp v0.9.4`. + +## [0.4.0] - 2025-12-26 + +### Breaking Changes +- **Default paths changed** from `~/.rmcp_servers/rmcp_mux/` to `~/.rmcp-servers/rust-mux/`. +- **Proxy command** changed from `rmcp_mux_proxy` to `rust-mux-proxy`. + +### Added +- **Daemon Status Socket** - Query running daemon status via Unix socket. +- **Heartbeat System** - Configurable health checks for MCP servers. + - `heartbeat_enabled` - Enable/disable per-server heartbeat + - `heartbeat_interval_ms` - Check interval (default: 30s) + - `heartbeat_timeout_ms` - Timeout before marking unhealthy +- **Tray Dashboard** - Multi-server status view in system tray. +- **Standalone Build** - Inlined common types, no workspace dependencies. + +### Changed +- Default socket directory: `~/.rmcp-servers/rust-mux/sockets`. +- Default service name: `rust-mux` (hyphenated). +- Detection now matches both `rust-mux` and legacy `rmcp_mux` patterns. +- Updated to Rust Edition 2024 (stable). + +### Fixed +- Consistent naming across paths, commands, and documentation. + +## [0.3.4] - 2025-12-20 + +### Fixed +- Minor bug fixes and stability improvements. + ## [0.3.0] - 2025-12-04 ### Added -- **Library-first architecture** – rmcp-mux is now an embeddable Rust library, not just a CLI tool. +- **Library-first architecture** – rust-mux is now an embeddable Rust library, not just a CLI tool. - `MuxConfig` builder for programmatic configuration: ```rust let config = MuxConfig::new("/tmp/mcp.sock", "npx") @@ -22,102 +129,28 @@ All notable changes to this project will be documented in this file. - Feature flags: `cli` (wizard, scan, binaries) and `tray` (system tray icon). ### Changed -- **Package renamed** from `rmcp_mux` to `rmcp-mux` (crates.io convention). -- **Binary renamed** from `rmcp_mux` to `rmcp-mux`, proxy from `rmcp_mux_proxy` to `rmcp-mux-proxy`. -- **Library name** remains `rmcp_mux` (Rust identifier requirement) – use `use rmcp_mux::*` in code. -- Project structure reorganized: - - `src/lib.rs` – new library entry point with public API. - - `src/bin/rmcp_mux.rs` – CLI binary (requires `cli` feature). - - `src/bin/rmcp_mux_proxy.rs` – proxy binary (requires `cli` feature). -- `runtime/mod.rs` split: `run_mux` now delegates to `run_mux_internal` with external shutdown support. -- `config.rs`: `resolve_params` now generic over `CliOptions` trait. -- Default features: `["cli", "tray"]` – use `default-features = false` for library-only. - -### Migration Guide -**From CLI to Library:** -```rust -// Before: rmcp-mux --socket /tmp/mcp.sock --cmd npx -- @mcp/server -// After: -use rmcp_mux::{MuxConfig, run_mux_server}; -let config = MuxConfig::new("/tmp/mcp.sock", "npx") - .with_args(vec!["@mcp/server".into()]); -run_mux_server(config).await?; -``` - -**Multiple servers in one process:** -```rust -use rmcp_mux::{MuxConfig, spawn_mux_server}; -let h1 = spawn_mux_server(MuxConfig::new("/tmp/a.sock", "server-a")).await?; -let h2 = spawn_mux_server(MuxConfig::new("/tmp/b.sock", "server-b")).await?; -// Both run in single process, sharing tokio runtime -``` - -## [0.2.1] - 2025-11-27 +- **Rebranded: `rmcp_mux` β†’ `rust-mux`.** Crate name hyphenated on crates.io per convention; module path `rust_mux`. Binary `rmcp_mux_proxy` β†’ `rust_mux_proxy`. All internal imports `use rmcp_mux::` β†’ `use rust_mux::`. User-facing `RMCP_MUX_*` environment variables preserved for backward compatibility. +- **Moved to Loctree org:** `https://github.com/VetCoders/rust-mux` β†’ `https://github.com/Loctree/rust-mux`. ### Added -- **Three-step wizard flow** for comprehensive MCP configuration: - - **Step 1: Server Detection** – Detects running MCP server processes via `ps`, loads existing config, allows selection with `Space`, shows health status indicators. - - **Step 2: Client Detection** – Discovers MCP client applications (Codex, Cursor, VSCode, Claude, JetBrains), shows rewire status, allows selection for rewiring. - - **Step 3: Confirmation** – Summary of selections with save options: Save All, Mux Only, Clipboard, Back, Exit. -- Clipboard support (`pbcopy` on macOS) for copying config without writing files. -- Client rewiring functionality – automatically updates client configs to use `rmcp_mux_proxy`. -- Health status indicators in wizard: green dot (healthy), red dot (unhealthy), gray circle (unknown). -- Source indicators in wizard: `[C]` for config-based servers, `[D]` for detected processes. - -### Changed -- **Major refactoring** of `wizard.rs` (1829 LOC) into modular structure: - - `wizard/types.rs` – Enums and structs (WizardStep, Field, Panel, ServiceEntry, ClientEntry, etc.) - - `wizard/services.rs` – Service loading, MCP process detection, health checks - - `wizard/clients.rs` – Client (host application) detection - - `wizard/ui.rs` – All UI drawing functions (ratatui) - - `wizard/keys.rs` – Key event handling - - `wizard/persist.rs` – Config persistence and client rewiring - - `wizard/mod.rs` – Entry point and re-exports -- **Major refactoring** of `runtime.rs` (1596 LOC) into modular structure: - - `runtime/types.rs` – ServerEvent and constants (MAX_QUEUE, MAX_PENDING) - - `runtime/client.rs` – Client connection handling - - `runtime/server.rs` – MCP child process management with restart logic - - `runtime/proxy.rs` – STDIO proxy for mux socket - - `runtime/status.rs` – Status file writing - - `runtime/mod.rs` – Main mux loop, health check, timeout reaper - - `runtime/tests.rs` – All runtime tests (753 LOC) -- Improved wizard navigation: `n` for next step, `p` for previous step. -- Backup files (`.bak`) created for all modified configs. +- Package metadata: `description`, `repository`, `homepage`, `documentation`, `readme`, `keywords`, `categories`, `license = "MIT OR Apache-2.0"`, and `authors = ["Maciej Gad ", "Monika Szymanska "]` in `Cargo.toml` for proper crates.io listing and discovery. -### Fixed -- Redundant `scan_host_file` calls in client detection – now scans once and reuses result. - -## [0.2.0] - 2025-11-24 +## 0.2.0 - 2025-11-24 ### Added -- Optional tray icon (`--tray`) showing live server status, client and pending counts, and restart reasons. ([5eefde4](https://github.com/Loctree/rmcp-mux/commit/5eefde4)) -- Config file support (JSON/YAML/TOML) with auto-detection and CLI overrides. ([5eefde4](https://github.com/Loctree/rmcp-mux/commit/5eefde4)) -- `rmcp_mux_proxy` helper binary plus launchd template and installer tweaks for easier setup. ([04e5402](https://github.com/Loctree/rmcp-mux/commit/04e5402)) -- GitHub Actions CI workflow for formatting, linting, testing, and coverage, including an async proxy forwarding test. ([ad2b9aa](https://github.com/Loctree/rmcp-mux/commit/ad2b9aa)) -- Mux hooks, Semgrep rules, and expanded README documentation. ([e80083c](https://github.com/Loctree/rmcp-mux/commit/e80083c)) +- Optional tray icon (`--tray`) showing live server status, client and pending counts, and restart reasons. ([5eefde4](https://github.com/LibraxisAI/rust_mux/commit/5eefde4)) +- Config file support (JSON/YAML/TOML) with auto-detection and CLI overrides. ([5eefde4](https://github.com/LibraxisAI/rust_mux/commit/5eefde4)) +- `rust-mux-proxy` helper binary plus launchd template and installer tweaks for easier setup. ([04e5402](https://github.com/LibraxisAI/rust_mux/commit/04e5402)) +- GitHub Actions CI workflow for formatting, linting, testing, and coverage, including an async proxy forwarding test. ([ad2b9aa](https://github.com/LibraxisAI/rust_mux/commit/ad2b9aa)) +- Mux hooks, Semgrep rules, and expanded README documentation. ([e80083c](https://github.com/LibraxisAI/rust_mux/commit/e80083c)) - `health` subcommand to resolve config and assert socket reachability, plus unit tests for healthy/missing sockets. ### Changed -- Refactored mux state management and tray functionality into dedicated `state` and `tray` modules, with tray dependencies gated behind an optional `tray` feature; CI updated to run with `--no-default-features`. ([0d60764](https://github.com/Loctree/rmcp-mux/commit/0d60764), [ad2b9aa](https://github.com/Loctree/rmcp-mux/commit/ad2b9aa)) - -## [0.1.5] - 2025-11-20 +- Refactored mux state management and tray functionality into dedicated `state` and `tray` modules, with tray dependencies gated behind an optional `tray` feature; CI updated to run with `--no-default-features`. ([0d60764](https://github.com/LibraxisAI/rust_mux/commit/0d60764), [ad2b9aa](https://github.com/LibraxisAI/rust_mux/commit/ad2b9aa)) -### Added -- JSON status snapshots (`--status-file` / `status_file`) including PID, queue depth, request limits, restart/backoff settings. +## 0.1.5 +- Added JSON status snapshots (`--status-file` / `status_file`) including PID, queue depth, request limits, restart/backoff settings. - Hardened runtime: lazy child start, request size guard, request timeouts, capped restart backoff, max restarts. -- Status writer task for tray/automation; MuxState now tracks queue depth and child PID. - -### Changed - Config/Wizard/Scan updated to surface new fields; defaults documented in README. +- Status writer task for tray/automation; MuxState now tracks queue depth and child PID. - Tests cover initialize cache, resets, status snapshots, and proxy; CI runs fmt/clippy/tests/tarpaulin with `--no-default-features` (tray off in CI). - -## [0.1.0] - 2025-11-15 - -### Added -- Initial release of rmcp-mux. -- Single MCP server child process management. -- Unix socket listener for multiple clients. -- JSON-RPC ID rewriting per client. -- Initialize request caching and fan-out. -- Child process restart on failure. -- Basic CLI interface with `--socket`, `--cmd`, `--max-active-clients`, `--log-level`. diff --git a/Cargo.toml b/Cargo.toml index ee00400..219d506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,37 +1,37 @@ [package] -name = "rmcp-mux" -version = "0.3.4" +name = "rust-mux" +version = "0.4.0" edition = "2024" -description = "MCP server multiplexer - single server, multiple clients via Unix sockets" -license = "MIT" -repository = "https://github.com/Loctree/rmcp-mux" -keywords = ["mcp", "multiplexer", "rpc", "unix-socket"] -categories = ["network-programming", "development-tools"] +authors = ["Maciej Gad ", "Monika Szymanska "] +description = "Transport multiplexer for Rust MCP servers β€” tray, proxy, and runtime supervisor" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Loctree/rust-mux" +homepage = "https://github.com/Loctree/rust-mux" +documentation = "https://docs.rs/rust-mux" readme = "README.md" +keywords = ["mcp", "multiplexer", "proxy", "tray", "supervisor"] +categories = ["command-line-utilities", "development-tools"] [lib] -name = "rmcp_mux" +name = "rust_mux" path = "src/lib.rs" [[bin]] -name = "rmcp-mux" -path = "src/bin/rmcp_mux.rs" +name = "rust-mux" +path = "src/bin/rust-mux.rs" required-features = ["cli"] [[bin]] -name = "rmcp-mux-proxy" -path = "src/bin/rmcp_mux_proxy.rs" +name = "rust-mux-proxy" +path = "src/bin/rust-mux-proxy.rs" required-features = ["cli"] [features] -default = ["cli", "tray"] -# CLI features (wizard, scan commands) -cli = ["clap", "ratatui", "crossterm"] -# System tray icon support +default = ["tray", "cli"] tray = ["tray-icon", "image"] +cli = ["clap", "ratatui", "crossterm", "tracing-subscriber"] [dependencies] -# Core dependencies (always required) anyhow = "1" futures = "0.3" serde = { version = "1", features = ["derive"] } @@ -40,17 +40,17 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["codec"] } tracing = "0.1" bytes = "1" -rmcp = "0.12" +rmcp = "1.6" serde_yaml = "0.9" -toml = "0.8" +toml = "0.9.1" uuid = { version = "1", features = ["v4"] } +crossbeam-channel = "0.5" # CLI dependencies (optional) clap = { version = "4", features = ["derive"], optional = true } -ratatui = { version = "0.28", optional = true } -crossterm = { version = "0.27", optional = true } -tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -crossbeam-channel = "0.5" +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"], optional = true } # Tray dependencies (optional) tray-icon = { version = "0.21.2", optional = true } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..82686a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,97 @@ +License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +Parameters + +Licensor: VetCoders (Maciej Gad & Monika Szymanska) +Licensed Work: Rust Multiplexer - The Connectivity Driver for + Intentions Engine. + The Licensed Work is (c) 2024-2026 VetCoders. +Additional Use Grant: You may make production use of the Licensed Work, + provided Your use does not include offering the + Licensed Work to third parties on a hosted or embedded + basis in order to compete with VetCoders' paid + version(s) of the Licensed Work. For purposes of this + license: + + A "competitive offering" is a Product that is offered + to third parties on a paid basis, including through + paid support arrangements, that significantly overlaps + with the capabilities of VetCoders' paid version(s) of + the Licensed Work. If Your Product is not a competitive + offering when You first make it generally available, it + will not become a competitive offering later due to + VetCoders releasing a new version of the Licensed Work + with additional capabilities. In addition, Products + that are not provided on a paid basis are not + competitive. + + "Product" means software that is offered to end users + to manage in their own environments or offered as a + service on a hosted basis. + + "Embedded" means including the source code or + executable code from the Licensed Work in a competitive + offering. "Embedded" also means packaging the + competitive offering in such a way that the Licensed + Work must be accessed or downloaded for the competitive + offering to operate. + + Hosting or using the Licensed Work(s) for internal + purposes within an organization is not considered a + competitive offering. + + Individual developers and small teams (fewer than 5 + people) may use the Licensed Work in production free of + charge, provided their use does not constitute a + competitive offering. +Change Date: 2030-03-24 +Change License: Apache License, Version 2.0 + +For information about alternative licensing arrangements for the Licensed +Work, please contact void@div0.space. + +Notice + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c367204 --- /dev/null +++ b/Makefile @@ -0,0 +1,103 @@ +SHELL := /bin/bash + +.PHONY: help build release check fmt fmt-check clippy test test-full gates \ + wizard wizard-dry-run run run-tray proxy health daemon-status dashboard \ + status-file-init clean-runtime + +CONFIG ?= $(HOME)/.codex/mcp-mux.toml +SERVICE ?= general-memory +SOCKET ?= /tmp/$(SERVICE).sock +CMD ?= npx +CMD_ARGS ?= @modelcontextprotocol/server-memory +STATUS_FILE ?= $(HOME)/.rust-mux/status/$(SERVICE).json +LOG_LEVEL ?= info + +help: + @echo "rust-mux targets" + @echo " build - cargo build" + @echo " release - cargo build --release" + @echo " check - cargo check --all-targets --all-features" + @echo " fmt - cargo fmt --all" + @echo " fmt-check - cargo fmt -- --check" + @echo " clippy - cargo clippy --all-targets --all-features -- -D warnings" + @echo " test - cargo test --all-targets --all-features" + @echo " test-full - test + opcjonalny pakiet testΓ³w transportu (ignored)" + @echo " gates - fmt-check + clippy + test" + @echo "" + @echo "Wizard / runtime" + @echo " wizard - uruchom interaktywny wizard (zapisuje config)" + @echo " wizard-dry-run - uruchom wizard bez zapisu" + @echo " run - uruchom mux dla SERVICE z CONFIG" + @echo " run-tray - uruchom mux z tray + STATUS_FILE" + @echo " proxy - uruchom rust-mux-proxy dla SOCKET" + @echo " health - health check dla SERVICE z CONFIG" + @echo " daemon-status - status wszystkich usΕ‚ug z daemona" + @echo " dashboard - uruchom tray dashboard (feature: tray)" + @echo " status-file-init - utwΓ³rz katalog dla STATUS_FILE" + @echo " clean-runtime - usuΕ„ SOCKET i STATUS_FILE" + @echo "" + @echo "Zmienne (override np. 'make run SERVICE=brave-search'):" + @echo " CONFIG=$(CONFIG)" + @echo " SERVICE=$(SERVICE)" + @echo " SOCKET=$(SOCKET)" + @echo " STATUS_FILE=$(STATUS_FILE)" + @echo " CMD=$(CMD)" + @echo " CMD_ARGS=$(CMD_ARGS)" + @echo " LOG_LEVEL=$(LOG_LEVEL)" + +build: + cargo build + +release: + cargo build --release + +check: + cargo check --all-targets --all-features + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt -- --check + +clippy: + cargo clippy --all-targets --all-features -- -D warnings + +test: + cargo test --all-targets --all-features + +test-full: + cargo test --all-targets --all-features + cargo test --all-targets --all-features -- --ignored + +gates: fmt-check clippy test + +wizard: + cargo run --bin rust-mux -- wizard --config "$(CONFIG)" --service "$(SERVICE)" + +wizard-dry-run: + cargo run --bin rust-mux -- wizard --config "$(CONFIG)" --service "$(SERVICE)" --dry-run + +run: + cargo run --bin rust-mux -- --config "$(CONFIG)" --service "$(SERVICE)" --log-level "$(LOG_LEVEL)" + +run-tray: status-file-init + cargo run --bin rust-mux -- --config "$(CONFIG)" --service "$(SERVICE)" --tray --status-file "$(STATUS_FILE)" --log-level "$(LOG_LEVEL)" + +proxy: + cargo run --bin rust-mux-proxy -- --socket "$(SOCKET)" + +health: + cargo run --bin rust-mux -- health --config "$(CONFIG)" --service "$(SERVICE)" + +daemon-status: + cargo run --bin rust-mux -- daemon-status + +dashboard: status-file-init + cargo run --features tray --bin rust-mux -- dashboard --status-file "$(STATUS_FILE)" + +status-file-init: + mkdir -p "$(dir $(STATUS_FILE))" + +clean-runtime: + rm -f "$(SOCKET)" "$(STATUS_FILE)" diff --git a/README.md b/README.md index 0b44bed..fe72389 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,96 @@ -# rmcp-mux – MCP Server Multiplexer - -[![CI](https://github.com/Loctree/rmcp-mux/actions/workflows/ci.yml/badge.svg)](https://github.com/Loctree/rmcp-mux/actions/workflows/ci.yml) -[![Crates.io](https://img.shields.io/crates/v/rmcp-mux.svg)](https://crates.io/crates/rmcp-mux) -[![Version](https://img.shields.io/badge/version-0.3.4-blue.svg)](Cargo.toml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) - -A Rust daemon that manages **all your MCP servers from a single process**. Define servers in `mux.toml`, run one `rmcp-mux` command, and get Unix sockets for each service. Features ID rewriting, initialize caching, auto-restart, and an interactive TUI dashboard. - -**NEW in 0.3.x**: Unified process model with daemon status socket! One `rmcp-mux` process manages all servers from config file. Interactive TUI dashboard for monitoring and control. Query live status via `rmcp-mux daemon-status`. - -## Table of Contents -- [Features](#features) -- [Quick Start](#quick-start) -- [Installation](#installation) -- [Configuration](#configuration) -- [CLI Reference](#cli-reference) -- [Interactive TUI Dashboard](#interactive-tui-dashboard) -- [Library Usage](#library-usage) -- [Runtime Behavior](#runtime-behavior) -- [Project Structure](#project-structure) -- [Testing](#testing) -- [Contributing](#contributing) +# rust-mux – shared MCP server daemon -## Features - -### Core -- **Single process, multiple servers** – one `rmcp-mux` manages all MCP servers defined in config -- **Unix socket per service** – each server gets its own socket for client connections -- **ID rewriting** – responses matched to correct client across all services -- **Initialize caching** – executed once per server; cached response served to subsequent clients -- **Auto-restart** – servers restart on failure with exponential backoff -- **Graceful shutdown** – Ctrl+C stops all servers, removes sockets - -### Monitoring & Control -- **Interactive TUI** (`--tui`) – real-time dashboard with server status, restart controls -- **Status command** (`--show-status`) – JSON snapshot of all server states -- **Per-server control** – restart, stop, start individual servers at runtime -- **Selective startup** – `--only` and `--except` flags for partial launches - -### Configuration -- **TOML config** – simple, readable server definitions -- **Environment variables** – per-server env injection -- **Flexible parameters** – timeouts, client limits, restart policies - -## Quick Start - -```bash -# 1. Create config file -cat > mux.toml << 'EOF' -[servers.memory] -socket = "/tmp/mcp-memory.sock" -cmd = "npx" -args = ["-y", "@modelcontextprotocol/server-memory"] - -[servers.filesystem] -socket = "/tmp/mcp-fs.sock" -cmd = "npx" -args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] -EOF +Small Rust daemon that lets many MCP clients reuse a single STDIO server process (e.g. `npx @modelcontextprotocol/server-memory`) over a Unix socket. It rewrites JSON-RPC IDs per client, caches `initialize`, restarts the child on failure, and cleans up the socket on exit. -# 2. Start all servers -rmcp-mux --config mux.toml - -# 3. Or start with TUI dashboard -rmcp-mux --config mux.toml --tui - -# 4. Connect via proxy (for MCP hosts expecting STDIO) -rmcp-mux-proxy --socket /tmp/mcp-memory.sock +## Features +- One child process per service (spawned from `--cmd ...`). +- Many clients via Unix socket; ID rewriting keeps responses matched to the right client. +- `initialize` is executed once; later clients get the cached response immediately. +- Concurrent requests allowed; active client slots limited by `--max-active-clients` (default 5). +- Notifications are broadcast to all connected clients. +- Restart-on-exit for the child; pending/waiting requests receive an error on reset. +- Ctrl+C stops the mux, kills the child, and removes the socket file. +- Optional JSON status snapshots (`--status-file`) for tray/automation (PID, restarts, queue depth). +- Optional tray indicator (`--tray`) shows live server status (running/restarting), client and pending counts, initialize cache state, and restart reason. + +## Build ``` - -## Installation - -### From source -```bash cargo build --release -# Binaries: target/release/rmcp-mux, target/release/rmcp-mux-proxy ``` +Binaries live in `target/release/rust-mux`. -### One-liner (curl | sh) -```bash -curl -fsSL https://raw.githubusercontent.com/Loctree/rmcp-mux/main/tools/install.sh | sh +## Install (curl | sh) ``` +curl -fsSL https://raw.githubusercontent.com/Loctree/rust-mux/main/tools/install.sh | sh +``` +- Places wrapper in `$HOME/.local/bin/rust-mux` and ensures PATH contains cargo bin + wrapper dir. +- Env overrides: `INSTALL_DIR`, `CARGO_HOME`, `MUX_REF` (branch/tag, default main), `MUX_NO_LOCK=1` to skip `--locked`. -**Environment overrides:** -- `INSTALL_DIR` – wrapper location (default: `$HOME/.local/bin`) -- `CARGO_HOME` – cargo home (default: `~/.cargo`) -- `MUX_REF` – branch/tag/commit (default: `main`) -- `MUX_NO_LOCK=1` – skip `--locked` flag - -### Built-in proxy -If your MCP host needs a STDIO command, use the bundled proxy: -```bash -rmcp-mux-proxy --socket /tmp/mcp-memory.sock +### Built-in proxy (no socat required) +If your MCP host wants a STDIO command, use the bundled proxy: +``` +rust-mux-proxy --socket /tmp/mcp-memory.sock ``` +Point host config to `rust-mux-proxy` with the matching socket path. -## Configuration +## Run (example: memory server) +``` +./target/release/rust-mux \ + --socket /tmp/mcp-memory.sock \ + --cmd npx -- @modelcontextprotocol/server-memory \ + --max-active-clients 5 \ + --log-level info -### Config file format (TOML) +``` -```toml -[servers.memory] -socket = "~/mcp-sockets/memory.sock" +## Config-driven run (JSON/YAML/TOML) +- Default config path: `~/.codex/mcp.json` (override via `--config `). Parser auto-detects by extension (`.json`, `.yaml`/`.yml`, `.toml`). +- JSON: +``` +{ + "servers": { + "general-memory": { + "socket": "~/mcp-sockets/general-memory.sock", + "cmd": "npx", + "args": ["@modelcontextprotocol/server-memory"], + "max_active_clients": 5, + "max_request_bytes": 1048576, + "request_timeout_ms": 30000, + "restart_backoff_ms": 1000, + "restart_backoff_max_ms": 30000, + "max_restarts": 5, + "status_file": "~/.rmcp_servers/rust_mux/status.json", + "lazy_start": false, + "tray": true, + "service_name": "general-memory" + } + } +} +``` +- YAML: +``` +servers: + general-memory: + socket: "~/mcp-sockets/general-memory.sock" + cmd: "npx" + args: ["@modelcontextprotocol/server-memory"] + max_active_clients: 5 + max_request_bytes: 1048576 + request_timeout_ms: 30000 + restart_backoff_ms: 1000 + restart_backoff_max_ms: 30000 + max_restarts: 5 + status_file: "~/.rmcp_servers/rust_mux/status.json" + lazy_start: false + tray: true + service_name: "general-memory" +``` +- TOML: +``` +[servers.general-memory] +socket = "~/mcp-sockets/general-memory.sock" cmd = "npx" -args = ["-y", "@modelcontextprotocol/server-memory"] +args = ["@modelcontextprotocol/server-memory"] max_active_clients = 5 tray = true @@ -120,13 +108,20 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/docs"] lazy_start = true [servers.rmcp-memex] -socket = "~/.rmcp_servers/sockets/rmcp-memex.sock" -cmd = "/path/to/rmcp_memex" +socket = "~/.rmcp-servers/sockets/rmcp-memex.sock" +cmd = "/path/to/rmcp-memex" args = ["serve", "--config", "config.toml", "--db-path", "~/.ai-memories/lancedb"] -env = { SLED_PATH = "~/.rmcp_servers/sled/memex" } +env = { SLED_PATH = "~/.rmcp-servers/sled/memex" } +max_request_bytes = 1048576 +request_timeout_ms = 30000 +restart_backoff_ms = 1000 +restart_backoff_max_ms = 30000 +max_restarts = 5 +status_file = "~/.rmcp_servers/rust_mux/status.json" lazy_start = false +tray = true +service_name = "general-memory" ``` - ### Parameter reference | Parameter | Default | Description | @@ -147,301 +142,134 @@ lazy_start = false ### Client Configuration (Claude Desktop, etc.) -MCP hosts expecting STDIO communication connect through `rmcp-mux-proxy`: +MCP hosts expecting STDIO communication connect through `rust-mux-proxy`: -```json -{ - "mcpServers": { - "rmcp-memex": { - "command": "rmcp-mux-proxy", - "args": ["--socket", "~/.rmcp_servers/sockets/rmcp-memex.sock"] - }, - "loctree": { - "command": "rmcp-mux-proxy", - "args": ["--socket", "~/.rmcp_servers/sockets/loctree.sock"] - }, - "brave-search": { - "command": "rmcp-mux-proxy", - "args": ["--socket", "~/.rmcp_servers/sockets/brave-search.sock"] - } - } -} ``` - -Each proxy instance translates STDIO <-> Unix socket, allowing standard MCP hosts to communicate with rmcp-mux managed servers. - -## CLI Reference - -### Main command - -```bash -rmcp-mux --config mux.toml [OPTIONS] +./target/release/rust-mux --config ~/.codex/mcp.json --service general-memory ``` - -**Required:** -- `--config ` – Path to configuration file (TOML) - -**Server selection:** -- `--only ` – Start only specified servers (comma-separated) -- `--except ` – Start all servers except specified (comma-separated) - -**Runtime control:** -- `--show-status` – Show status of all servers and exit -- `--restart-service ` – Restart a specific server -- `--tui` – Launch interactive TUI dashboard - -**Examples:** -```bash -# Start all servers from config -rmcp-mux --config mux.toml - -# Start only memory and filesystem servers -rmcp-mux --config mux.toml --only memory,filesystem - -# Start all except brave-search -rmcp-mux --config mux.toml --except brave-search - -# Check status of running servers -rmcp-mux --config mux.toml --status - -# Restart a specific server -rmcp-mux --config mux.toml --restart-service memory - -# Interactive dashboard -rmcp-mux --config mux.toml --tui +- CLI flags still override config (e.g. `--socket`, `--cmd`, `--tray`). + +### Resolution order & defaults +- `socket` / `cmd`: required (either CLI or config). `--service` is required when `--config` is provided. +- `args`: CLI `--` tail wins, otherwise config, otherwise empty. +- `max_active_clients`: CLI default 5 unless overridden by config entry. +- `lazy_start`: default `false`. +- `max_request_bytes`: default `1_048_576` (1β€―MiB). +- `request_timeout_ms`: default `30_000` (30β€―s). +- `restart_backoff_ms`: default `1_000` (1β€―s), capped by `restart_backoff_max_ms` (default `30_000`). +- `max_restarts`: default `5` (0 = unlimited). +- `tray`: default `false`. +- `service_name`: CLI `--service-name`, else config, else socket file stem, else `rust_mux`. +- `status_file`: optional; accepts `~` and absolute/relative paths. + +### Interactive wizard (TUI) +- Launch a guided editor (ratatui) to build/update your mux config: ``` - -### Subcommands - -#### `daemon-status` – Query running daemon -```bash -# Get status of running daemon (requires daemon to be running) -rmcp-mux daemon-status - -# Output as JSON -rmcp-mux daemon-status --json - -# Use custom status socket -rmcp-mux daemon-status --socket /custom/path.sock +rust-mux wizard --config ~/.codex/mcp-mux.toml --service general-memory ``` - -Returns: version, uptime, server count, per-server status (active clients, pending requests, restarts, heartbeat latency). - -#### `health` – Verify connectivity -```bash -# Check specific socket -rmcp-mux health --socket /tmp/mcp-memory.sock - -# Check service from config -rmcp-mux health --config mux.toml --service memory +- Controls: `↑/↓` move, `Enter` edit field, `Space` toggle tray, `s` save, `q` quit. Saves JSON/YAML/TOML based on the extension; creates a `.bak` before overwriting. +- `--dry-run` runs the wizard without writing files. +- `--import-config ` (repeatable) imports a workspace-local or otherwise non-default MCP config file (JSON or TOML). The wizard auto-detects the schema (`mcpServers`/`servers` for JSON, `[mcp_servers.*]` for TOML). + +#### Step 3 β€” confirm dialog actions + +| Action | What it does | +|-----------------|--------------| +| `SAFE GEN` | Writes `~/.config/mux/{config.toml,mcp.json,mcp.toml}`. `config.toml` is the daemon truth (original upstream commands), `mcp.json`/`mcp.toml` are client-facing snippets where every server runs `rust-mux-proxy --socket `. Never modifies any existing client config. Prints per-client setup commands. | +| `MUX ONLY` | Writes the legacy mux config to whichever path you passed via `--config`. | +| `CLIPBOARD` | Copies the mux TOML to the macOS clipboard (`pbcopy`). | +| `[DANGER] auto` | Backup-first preview-first rewrite of *existing* MCP server blocks in known client configs to use `rust-mux-proxy`. Wizard leaves the alternate screen, prints a full preview (planned changes per file, skipped sources with reasons), and refuses to mutate anything until the user types `CONFIRM`. Each modified file gets a timestamped `..bak` next to it; rollback commands are printed at the end. Files that fail to parse are *never* modified. | + +The `[DANGER]` flow understands real-world client realities: +- **Claude Code** & **Claude Desktop** are eligible (JSON `mcpServers` schema, surgical update keeps unrelated keys). +- **Codex** is eligible (TOML `[mcp_servers.]` schema). +- **Junie** is eligible (`~/.junie/mcp/mcp.json` plus generic `~/.agents/mcp.json` / `~/.ai/mcp.json`). +- **Gemini** is by default *ineligible* β€” there's no observed strict-config flag, so the wizard prefers generated `gemini mcp add ...` commands in the safe path. You can still aim a `--import-config` at a Gemini settings file for inspection. + +#### Per-client guidance the safe path prints + +After `SAFE GEN`, run the printed commands per client. Quick reference: +- Claude Code: `claude --strict-mcp-config --mcp-config "$HOME/.config/mux/mcp.json"` +- Claude Desktop: merge the `mcpServers` block from `~/.config/mux/mcp.json` into `~/Library/Application Support/Claude/claude_desktop_config.json` (no strict-config CLI flag in this variant). +- Codex CLI: merge the `[mcp_servers]` block from `~/.config/mux/mcp.toml` into `~/.codex/config.toml`, or run `codex mcp add ...` per server. (`codex --config k=v` is a key-value override, not a config-file flag β€” the wizard will not invent one.) +- Junie: `junie --mcp-location "$HOME/.config/mux/mcp.json"` (or `--mcp-default-locations` to keep additive.) +- Gemini CLI: one printed `gemini mcp add -- rust-mux-proxy --socket ` per discovered service. + +See `docs/WIZARD.md` for the full guided walk-through, conflict handling, and rollback procedure. + +### Dependency notes +- `ratatui` + `crossterm` power the TUI wizard; both are pure-Rust and optional (build with `--no-default-features` to skip). +- `tempfile` is dev-only for isolated FS fixtures in tests. + +### Scan and rewire host configs +- Detect MCP hosts (Codex, Cursor/VSCode, Claude, JetBrains paths) and build a mux manifest + host snippets that point to the bundled proxy: ``` - -#### `proxy` – STDIO proxy -```bash -rmcp-mux proxy --socket /tmp/mcp-memory.sock +rust-mux scan --manifest ~/.codex/mcp-mux.toml --snippet ~/.codex/mcp-mux ``` - #### `scan` – Discover and generate configs -```bash -rmcp-mux scan \ - --manifest ~/.codex/mcp-mux.toml \ - --snippet ~/.codex/mcp-mux \ - --socket-dir ~/.rmcp_servers/rmcp-mux/sockets ``` - -#### `rewire` – Update host configs -```bash -# Rewire a host config to use rmcp-mux proxy (creates .bak backup) -rmcp-mux rewire --host codex --socket-dir ~/.rmcp_servers/rmcp-mux/sockets - -# Preview changes without writing -rmcp-mux rewire --host codex --dry-run +rust-mux rewire --host codex --socket-dir ~/.rmcp-servers/rust-mux/sockets ``` - -#### `wizard` – Interactive configuration -```bash -rmcp-mux wizard --config ~/.codex/mcp-mux.toml +- Snippets use the installed `rust-mux-proxy` binary: `command = "rust-mux-proxy"; args = ["--socket", ""]`. +- Check whether a host is already pointed at the mux proxy: ``` - -## Interactive TUI Dashboard - -Launch with `--tui` for a real-time dashboard: - -```bash -rmcp-mux --config mux.toml --tui +rust-mux status --host codex --proxy-cmd rust-mux-proxy ``` -### Display -- Server list with status indicators (running/stopped/error) -- Connected clients count per server -- Pending requests -- Restart count and last restart reason -- Real-time updates - -### Keyboard controls - -| Key | Action | -|-----|--------| -| `j` / `Down` | Move selection down | -| `k` / `Up` | Move selection up | -| `r` | Restart selected server | -| `s` | Stop selected server | -| `S` | Start selected server | -| `q` / `Esc` | Quit | - -## Library Usage - -Add to your `Cargo.toml`: - -```toml -[dependencies] -rmcp-mux = { version = "0.3", default-features = false } +### Health check +- Verify that config resolves and the mux socket is reachable: ``` - -### Basic Example - Single Mux Server - -```rust -use rmcp_mux::{MuxConfig, run_mux_server}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let config = MuxConfig::new("/tmp/my-mcp.sock", "npx") - .with_args(vec!["-y".into(), "@anthropic/mcp-server".into()]) - .with_max_clients(10) - .with_service_name("my-mcp-server"); - - run_mux_server(config).await -} +rust-mux health --socket /tmp/mcp-memory.sock --cmd npx -- @modelcontextprotocol/server-memory ``` - -### Multiple Mux Instances (Single Process) - -```rust -use rmcp_mux::{MuxConfig, spawn_mux_server, MuxHandle}; -use std::time::Duration; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let services = vec![ - ("memory", "/tmp/mcp-memory.sock", "npx", vec!["@mcp/server-memory"]), - ("filesystem", "/tmp/mcp-fs.sock", "npx", vec!["@mcp/server-filesystem"]), - ]; - - let mut handles: Vec = Vec::new(); - for (name, socket, cmd, args) in services { - let config = MuxConfig::new(socket, cmd) - .with_args(args.into_iter().map(String::from).collect()) - .with_service_name(name) - .with_request_timeout(Duration::from_secs(60)); - - handles.push(spawn_mux_server(config).await?); - } - - for handle in handles { - handle.wait().await?; - } - Ok(()) -} +- With a config file: +``` +rust-mux health --config ~/.codex/mcp.json --service general-memory ``` -### Health Check - -```rust -use rmcp_mux::check_health; +## Tray status (optional) +- Run with `--tray` to spawn a small status icon. The drawer lists service name, server state, connected/active clients, pending requests, initialize cache state, and restart count/reason. +- Click β€œQuit mux” in the tray menu to stop the daemon (propagates shutdown to the child and cleans the socket). +- To feed your own UI/monitor, write status snapshots to JSON: `rust-mux --status-file ~/.rmcp_servers/rust_mux/status.json ...`. The file is updated on every state change. +``` -async fn verify_service() -> bool { - check_health("/tmp/mcp-memory.sock").await.is_ok() -} +### Proxy config for MCP hosts +Use the bundled proxy instead of `socat`: +``` +rust-mux-proxy --socket /tmp/mcp-memory.sock ``` +Do this per service (memory, brave-search, etc.) with distinct sockets and mux instances. -### Feature Flags - -| Feature | Default | Description | -|---------|---------|-------------| -| `cli` | yes | CLI binary, wizard, scan commands | -| `tray` | yes | System tray icon support | - -For library-only usage (minimal dependencies): - -```toml -[dependencies] -rmcp-mux = { version = "0.3", default-features = false } -``` - -## Runtime Behavior - -### Client handling -1. New client connects to server's socket -> assigned `client_id` -2. Messages get `global_id = c:` -3. Responses demuxed back to original client with local ID -4. First `initialize` hits server; response cached -5. Later `initialize` calls answered from cache - -### Safety guards -- **Max request size** – default 1 MiB -- **Request timeout** – default 30s with cleanup of pending calls -- **Exponential restart backoff** – 1s -> 30s with configurable limit -- **Lazy start** – defer child spawn until first request - -### Error handling -- Child exit or I/O failure -> restart child, clear cache/pending, send errors to affected clients -- Graceful shutdown (Ctrl+C) -> stop all children, delete all sockets - -## Project Structure - -``` -rmcp-mux/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ lib.rs # Library entry point, MuxConfig, public API -β”‚ β”œβ”€β”€ config.rs # Config types, loading, validation -β”‚ β”œβ”€β”€ state.rs # MuxState, StatusSnapshot, helpers -β”‚ β”œβ”€β”€ scan.rs # Host discovery and rewiring (cli feature) -β”‚ β”œβ”€β”€ tray.rs # Tray icon (tray feature) -β”‚ β”œβ”€β”€ bin/ -β”‚ β”‚ β”œβ”€β”€ rmcp_mux.rs # CLI binary (cli feature) -β”‚ β”‚ └── rmcp_mux_proxy.rs # STDIO proxy (cli feature) -β”‚ β”œβ”€β”€ runtime/ # Core mux daemon -β”‚ β”‚ β”œβ”€β”€ mod.rs # run_mux, health_check -β”‚ β”‚ β”œβ”€β”€ types.rs # ServerEvent, constants -β”‚ β”‚ β”œβ”€β”€ client.rs # Client connection handling -β”‚ β”‚ β”œβ”€β”€ server.rs # Child process management -β”‚ β”‚ β”œβ”€β”€ proxy.rs # STDIO proxy logic -β”‚ β”‚ β”œβ”€β”€ status.rs # Status file writing & daemon status socket -β”‚ β”‚ └── heartbeat.rs # Backend health monitoring -β”‚ └── wizard/ # Interactive TUI wizard (cli feature) -β”œβ”€β”€ tools/ -β”‚ β”œβ”€β”€ install.sh # One-liner installer -β”‚ └── launchd/ # macOS launchd templates -└── public/ - └── rmcp_mux_icon.png # Tray icon -``` - -## Testing - -```bash -# Run all tests +### launchd (macOS) example +A template lives at `tools/launchd/rust-mux.sample.plist`. Copy to `~/Library/LaunchAgents/`, replace paths/user, then: +``` +launchctl load -w ~/Library/LaunchAgents/rust-mux.general-memory.plist +``` +Label should be unique per service; logs go to the paths defined in the plist. + +## Runtime behavior +- New client β†’ assigned `client_id`, messages get `global_id = c:`. +- Responses are demuxed back to the original client/local ID. +- First `initialize` hits the server; the response is cached and fanned out to waiters. Later `initialize` calls are answered from cache. +- Guards: max request size (default 1β€―MiB), request timeout (default 30β€―s) with cleanup of pending calls, exponential restart backoff (1β€―s β†’ 30β€―s) with a default limit of 5 restarts, and optional lazy start (defer child spawn until the first request). +- If the child exits or write/read fails, the mux restarts it, clears cache/pending, and sends error responses to affected clients. +- On shutdown (Ctrl+C), the mux stops the child and deletes the socket. + +## Options +- `--socket `: Unix socket path. +- `--cmd ` `-- `: command to run the MCP server. +- `--max-active-clients `: limit of concurrently active clients (default 5). +- `--log-level `: trace|debug|info|warn|error (default info). + +## Tests and coverage +``` cargo test - -# Run tests without tray feature (for CI/headless) -cargo test --no-default-features - -# Linting cargo clippy --all-targets --all-features +cargo tarpaulin --all-targets --timeout 120 ``` +Current unit tests cover ID rewriting, initialize caching, and reset fan-out. Integration tests with a fake server can be added to raise coverage. -## launchd (macOS) - -Template at `tools/launchd/rmcp-mux.sample.plist`: -```bash -cp tools/launchd/rmcp-mux.sample.plist ~/Library/LaunchAgents/rmcp-mux.plist -# Edit paths: set --config to your mux.toml -launchctl load -w ~/Library/LaunchAgents/rmcp-mux.plist -``` - -## Contributing - -See [.ai-agents/AI_GUIDELINES.md](.ai-agents/AI_GUIDELINES.md) for development guidelines. - -## License - -MIT +## Notes and TODOs +- Extend health to include initialize ping and optional metrics (per client / per request). +- Consider persistent initialize params after child restart (auto re-init). +- Add configurable child restart backoff and max retries. +- Expand host detection/rewire coverage and add automated host-side validation. diff --git a/docs/WIZARD.md b/docs/WIZARD.md new file mode 100644 index 0000000..7e7229b --- /dev/null +++ b/docs/WIZARD.md @@ -0,0 +1,318 @@ +# rust-mux wizard β€” five-step flow + +> **Version:** 0.4.0 +> **Last updated:** 2026-05-06 + +The wizard takes you from "I have N MCP clients with overlapping servers" to +"every client multiplexes through one rust-mux daemon" without surprising you. +Five steps. Three strategies. Backups before any rewrite. + +``` +DiscoverySources β†’ ServerReview β†’ StrategyChoice β†’ SummaryConfirm β†’ ResultAndTray +``` + +```bash +make wizard # uses default ~/.codex/mcp-mux.toml +make wizard-dry-run # preview only, no writes + +# Or directly: +cargo run --bin rust-mux -- wizard --config ~/.codex/mcp-mux.toml +cargo run --bin rust-mux -- wizard --dry-run +cargo run --bin rust-mux -- wizard --import-config ~/.workspace/mcp.json +``` + +## Source of truth + +The wizard discovers MCP servers from your **client config files**, not from +running processes. That matters: Claude Code and Codex spawn MCP servers on +demand, so a `ps`-based scan would show "zero servers" outside an active +session. Reading the configs gives a true picture regardless of runtime +state. ps-scan is still used, but only as **enrichment** β€” it stamps PIDs on +matching entries and surfaces ps-only orphans. + +Default sources (auto-discovered if the file exists): + +| Path | Client | Schema | +|---|---|---| +| `~/.claude.json` | Claude Code | JSON `mcpServers` | +| `~/Library/Application Support/Claude/claude_desktop_config.json` | Claude Desktop | JSON `mcpServers` | +| `~/.codex/config.toml` | Codex CLI | TOML `[mcp_servers.]` | +| `~/.junie/mcp/mcp.json` | Junie | JSON `mcpServers` | +| `~/.agents/mcp.json` | Junie (generic) | JSON `mcpServers` / `servers` | +| `~/.ai/mcp.json` | Junie (generic) | JSON `mcpServers` / `servers` | +| `~/.gemini/settings.json` | Gemini CLI | JSON `mcpServers` / `servers` | +| `~/Library/Application Support/Cursor/User/settings.json` | Cursor | JSON (legacy) | +| `~/Library/Application Support/Code/User/settings.json` | VS Code | JSON (legacy) | +| `~/Library/Application Support/JetBrains/LLM/mcp.json` | JetBrains IDEs | JSON (legacy) | + +Use `--import-config ` (repeatable) on the CLI to pre-add custom +sources before the wizard starts. Inside STEP 1, `i` opens the custom-path +input field for ad-hoc additions. + +## STEP 1 β€” Discovery sources + +``` +β”Œβ”€Sources [4/8]─────────────────────────────────────┐ β”Œβ”€Custom path───────────────┐ +β”‚ β–Ά [x] [Claude Code ] ~/.claude.json 5 servers β”‚ β”‚ Add custom path β”‚ +β”‚ [x] [Codex CLI ] ~/.codex/config.toml 7 β”‚ β”‚ β”‚ +β”‚ [x] [Junie ] ~/.junie/mcp/mcp.json 3 β”‚ β”‚ > β”‚ +β”‚ [ ] [Gemini CLI ] ~/.gemini/settings.json β”‚ β”‚ β”‚ +β”‚ [ ] [Claude Desktop ] …/claude_desktop_config… β”‚ β”‚ Keys β”‚ +β”‚ [ ] [Cursor ] …/Cursor/User/settings.… β”‚ β”‚ Up/Down navigate β”‚ +β”‚ [ ] [VS Code ] …/Code/User/settings.js… β”‚ β”‚ Space toggle β”‚ +β”‚ [ ] [JetBrains ] …/LLM/mcp.json not found β”‚ β”‚ i custom path β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Enter add custom path β”‚ + β”‚ n next step β”‚ + β”‚ q quit β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Statuses surface the parse outcome (`N servers`, `empty`, `invalid`, +`not found`). Sources that don't exist are deselected by default; you can +flip them on if you plan to populate the file later. + +The custom-path field opens with `i`. Type a path (`~` is expanded), then +`Enter` to add it, `Esc` to abandon. The newly added source is selected +automatically. + +## STEP 2 β€” Server review + +``` +β”Œβ”€Servers [9/15]─────────────────────────┐ β”Œβ”€Review───────────────────────┐ +β”‚ ─ claude β”‚ β”‚ Summary β”‚ +β”‚ β–Ά [x] aicx-mcp β”‚ β”‚ β”‚ +β”‚ [x] brave-search β”‚ β”‚ Total entries : 15 β”‚ +β”‚ [x] loctree-mcp β”‚ β”‚ Unique names : 9 β”‚ +β”‚ [x] context7 β”‚ β”‚ Sources scanned: 3 β”‚ +β”‚ [x] memex β”‚ β”‚ β”‚ +β”‚ ─ codex β”‚ β”‚ Keys β”‚ +β”‚ [x] playwright β”‚ β”‚ Up/Down navigate β”‚ +β”‚ [x] chrome-devtools (pid 21470) β”‚ β”‚ Space toggle β”‚ +β”‚ [x] curl β”‚ β”‚ n next step β”‚ +β”‚ [x] youtube β”‚ β”‚ p previous step β”‚ +β”‚ ─ junie β”‚ β”‚ q quit β”‚ +β”‚ (deduped against claude/junie items) β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Servers are grouped by their originating client. Identical entries across +clients (same `command + args + env`) collapse into one. If two clients +disagree on how to launch the same logical server, both variants are kept +and renamed with deterministic `-from-` suffixes; the right panel +surfaces the conflict count. + +PID badges (`pid 21470`) appear on entries whose `(cmd, args)` match a +running process β€” the `enrich_running_state` pass. + +You can untick any entry to drop it from the mux output. Defaults to all +selected. + +## STEP 3 β€” Strategy + +``` +β”Œβ”€Strategy────────────────────────────────────────────────────────────────┐ +β”‚ How do you want to use mux? β”‚ +β”‚ β”‚ +β”‚ (β€’) 1. Unified config β”‚ +β”‚ Write one ~/.config/mux/{config.toml,mcp.json,mcp.toml} with β”‚ +β”‚ every selected server. Recommended. β”‚ +β”‚ β”‚ +β”‚ ( ) 2. Per-client configs β”‚ +β”‚ Write a separate file per client kind (claude.json, codex.toml, β”‚ +β”‚ junie.json, …) under ~/.config/mux/. β”‚ +β”‚ β”‚ +β”‚ ( ) 3. [DANGER] Auto-rewire existing client configs β”‚ +β”‚ Backup-first preview-first rewrite of your real client configs β”‚ +β”‚ to route through rust-mux-proxy. β”‚ +β”‚ β”‚ +β”‚ Up/Down to choose, Enter or n to continue, p to go back, q to quit. β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +`1`, `2`, `3` quick-pick. Up/Down navigate. Enter or `n` advances. + +### Unified + +Writes three files into `~/.config/mux/`: + +| File | Role | +|---|---| +| `config.toml` | Daemon truth β€” what `rust-mux` should run upstream. | +| `mcp.json` | Client-facing JSON; every server's `command` is `rust-mux-proxy`. | +| `mcp.toml` | Same shape as `mcp.json` but in TOML for Codex-style tooling. | + +Per-client startup snippets are printed on STEP 5: + +``` +claude --strict-mcp-config --mcp-config "$HOME/.config/mux/mcp.json" +junie --mcp-location "$HOME/.config/mux/mcp.json" +gemini mcp add aicx-mcp -- rust-mux-proxy --socket $HOME/.config/mux/sockets/aicx-mcp.sock +``` + +(Codex has no flag to swap the entire config file; the snippet tells you +to merge `mcp.toml` into `~/.codex/config.toml` or use `codex mcp add`.) + +### Per-client + +Writes one mux config per originating client kind, in that client's +native format: + +``` +~/.config/mux/ + config.toml # daemon truth (merged across every selected source) + claude.json # only Claude's servers, in Claude Desktop's mcpServers shape + codex.toml # only Codex's servers, in [mcp_servers.] shape + junie.json # only Junie's servers, in mcpServers shape + … +``` + +Useful when you want different stacks for different agents β€” e.g. give +Claude five servers but Codex only two. Each client points at its own mux +file with the per-kind startup commands STEP 5 prints. + +### [DANGER] Auto-rewire + +Rewrites the user's existing client configs in-place to route through +`rust-mux-proxy`. Discipline: + +1. Every eligible source gets a timestamped backup + (`..bak`). +2. Before any disk write the wizard prints a full preview and prompts + for `CONFIRM` (uppercase) on cooked stdin. Anything else cancels. +3. Sources that fail to parse are recorded as `SkippedInvalid` and never + touched. +4. Sources flagged `eligible_for_danger = false` (currently Gemini's + `settings.json`) are listed but skipped β€” the safe path snippets are + the supported route for those clients. + +The result panel includes one rollback command per backup: + +``` +cp -p '~/.claude.json.1714956123.bak' '~/.claude.json' +``` + +## STEP 4 β€” Summary & confirm + +``` +β”Œβ”€Summary─────────────────────────────────────────┐ β”Œβ”€Action──────────┐ +β”‚ About to: β”‚ β”‚ Choose action β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ Strategy : Unified β”‚ β”‚ β–Ά Confirm β”‚ +β”‚ Outputs : ~/.config/mux/config.toml β”‚ β”‚ Back β”‚ +β”‚ ~/.config/mux/mcp.json β”‚ β”‚ Cancel β”‚ +β”‚ ~/.config/mux/mcp.toml β”‚ β”‚ β”‚ +β”‚ Sockets : ~/.config/mux/sockets β”‚ β”‚ Up/Down: choose β”‚ +β”‚ Servers : 9 selected β”‚ β”‚ Enter: do it β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ DRY-RUN: no files will be modified. β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Strategy-specific previews: + +- **Unified** β€” three concrete output paths. +- **Per-client** β€” daemon `config.toml` plus one predicted file per + selected source kind. +- **Auto-rewire** β€” explicit list of files that *will* be rewritten, + plus the list of sources skipped because of `eligible_for_danger = + false`. + +Choose `Confirm`, `Back`, or `Cancel`. Confirm queues the strategy as a +`PendingAction` so it can run on cooked stdout/stdin once the alt screen +is dropped (the danger flow needs that for its `CONFIRM` prompt). + +## STEP 5 β€” Result & tray daemon + +``` +β”Œβ”€Result──────────────────────────────────────────┐ β”Œβ”€Action─────────────┐ +β”‚ Result β”‚ β”‚ Tray daemon β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ Wrote rust-mux config under ~/.config/mux: β”‚ β”‚ Run a multi- β”‚ +β”‚ - .../config.toml (daemon truth) β”‚ β”‚ service tray β”‚ +β”‚ - .../mcp.json (client JSON) β”‚ β”‚ monitor for the β”‚ +β”‚ - .../mcp.toml (client TOML) β”‚ β”‚ sockets you just β”‚ +β”‚ β”‚ β”‚ configured? β”‚ +β”‚ Use it from your AI clients: β”‚ β”‚ β”‚ +β”‚ β€’ Claude Code (strict config) β”‚ β”‚ β–Ά Start tray β”‚ +β”‚ claude --strict-mcp-config … β”‚ β”‚ daemon now β”‚ +β”‚ β€’ Codex CLI β”‚ β”‚ No, exit β”‚ +β”‚ # merge [mcp_servers] from …/mcp.toml β”‚ β”‚ β”‚ +β”‚ β€’ Junie β”‚ β”‚ Up/Down: choose β”‚ +β”‚ junie --mcp-location … β”‚ β”‚ Enter: confirm β”‚ +β”‚ β€’ Gemini CLI β”‚ β”‚ β”‚ +β”‚ gemini mcp add aicx-mcp … β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +`Start tray daemon now` spawns `rust-mux --tray --config ` +detached from this terminal (stdin/stdout/stderr β†’ `/dev/null`). `No` +exits cleanly. A persistent launchd-managed tray service is on the +roadmap; today the spawn is per-session. + +## Tray monitoring (after the wizard exits) + +The wizard's STEP 5 spawn is the convenience path. For long-running +workflows you'll usually want one of: + +- `rust-mux --config ~/.config/mux/config.toml` β€” run the multi-service + daemon in the foreground so you can see its logs. +- `rust-mux --tray --config ~/.config/mux/config.toml` β€” same, with the + tray-icon UI; quit it from the menu. +- `rust-mux daemon-status` (or `make daemon-status`) β€” query the + running multi-service daemon over its Unix socket and dump per-service + status. +- `rust-mux dashboard` (or `make dashboard`) β€” multi-service status + view in the system tray, reading the daemon's status snapshots. +- `rust-mux health --config ~/.config/mux/config.toml --service ` + β€” single-service socket reachability probe. + +## CLI flags + +``` +rust-mux wizard --config # mux daemon config (default ~/.codex/mcp-mux.toml) + --import-config # pre-load a custom MCP file as STEP 1 source (repeatable) + --dry-run # plan everything, write nothing +``` + +The legacy `--service`, `--socket`, `--cmd`, `--args`, `--max-clients`, +`--log-level`, `--tray` flags are accepted for backwards compatibility +and ignored by the 5-step flow. + +## Troubleshooting + +- **Empty source list on STEP 1.** No client config was found at the + default paths. Use `i` to add a custom path, or pass + `--import-config ` on the CLI. + +- **A source is "invalid".** STEP 1 surfaces the parser error in the + status panel after `i`. Common causes: trailing commas in JSON, + duplicate keys in TOML, `mcpServers` value that isn't an object. + +- **STEP 4 lists a file under "Skipped (no strict-config flag for + danger flow)".** That client (typically Gemini) doesn't have a + documented way to swap its entire MCP config file via a flag, so the + danger flow refuses to rewrite it. Use the safe-path snippets STEP 5 + prints instead β€” they cover that client's recommended `mcp add`-style + setup. + +- **`CONFIRM` prompt didn't appear after Auto-rewire.** It appears on + cooked stdout *after* the alt screen is dropped. If you see no + prompt, the plan had zero `Planned` actions (every selected source + was either invalid or ineligible). + +- **Tray daemon spawn says "Could not start … run manually".** Either + `rust-mux` isn't on `$PATH` or the config file you pointed at doesn't + exist. `cargo install --path .` (or copy the release binary onto + your `$PATH`) and try again. + +## See also + +- `docs/integration.md` β€” library use of `MuxConfig` / `spawn_mux_server`. +- `docs/vc-agents-client-discovery-plan.md` β€” original plan for the + multi-client discovery layer this wizard now consumes. +- `.vibecrafted/GUIDELINES.md` β€” repo-wide doctrine, including the + wizard's Unified / Per-client / Auto-rewire split. + +--- + +_πš…πš’πš‹πšŽπšŒπš›πšŠπšπšπšŽπš. with AI Agents by VetCoders (c)2024-2026 LibraxisAI_ diff --git a/docs/vc-agents-client-discovery-plan.md b/docs/vc-agents-client-discovery-plan.md new file mode 100644 index 0000000..05795d6 --- /dev/null +++ b/docs/vc-agents-client-discovery-plan.md @@ -0,0 +1,183 @@ +--- +run_id: rust-mux-client-discovery-config-generation +skill: vc-agents +project: rust-mux +status: pending +loops_completed: 0 +--- + +# Task: rust-mux MCP client discovery and config generation + +You are working on a living tree. Concurrent changes are expected. Adapt proactively and continue, but do not skip quality, security, or test gates. + +## Product intent + +`rust-mux` is a daemon/proxy for keeping one MCP server process alive and sharing it between many MCP clients through a socket/proxy transport. The current client discovery defaults are stale after the `rmcp_mux` β†’ `rust-mux` rebrand and after real CLI verification. + +Implement a client discovery and setup flow that stops pretending all MCP clients use one universal config model. The wizard must offer two explicit paths: + +1. Safe default: discover real MCP servers from known client configs, generate mux-owned configs under `~/.config/mux`, and print exact per-client usage instructions. Do not modify existing client configs. +2. `[DANGER] Automatically configure my clients`: after discovery, offer a backup-first and preview-first rewrite of known MCP server blocks in existing client configs so they point to `rust-mux-proxy` instead of directly starting upstream MCP servers. + +## Required real-world defaults + +Correct the discovery defaults according to observed local CLI behavior and current research: + +- Claude: + - `~/.claude.json` for Claude Code / global config. + - `~/Library/Application Support/Claude/claude_desktop_config.json` for Claude Desktop on macOS. + - Claude supports `--mcp-config ` and `--strict-mcp-config`. + - Recommended safe usage: `claude --strict-mcp-config --mcp-config "$HOME/.config/mux/mcp.json"`. +- Codex: + - Prefer `codex mcp list --json` / `codex mcp get --json` if useful and robust. + - File fallback: `~/.codex/config.toml`. + - `-c/--config` is a key-value override, not a config-file flag. Do not document fake `codex --config ~/.config/mux/mcp.toml` behavior. + - Codex TOML shape is expected around `[mcp_servers.]`. +- Junie: + - `~/.junie/mcp/mcp.json` high-confidence. + - `~/.agents/mcp.json` and `~/.ai/mcp.json` as generic medium-confidence agent config paths. + - Junie supports `--mcp-location` and `--mcp-default-locations`. + - Recommended safe usage: `junie --mcp-location "$HOME/.config/mux/mcp.json"`. +- Gemini: + - `~/.gemini/settings.json` if it contains MCP server config. + - Gemini exposes `gemini mcp list/add/remove/enable/disable`. + - No observed Claude-style strict config flag. Prefer generated instructions/commands or danger rewrite. +- Custom files: + - Support user-provided JSON/TOML paths. + - JSON with `mcpServers`. + - JSON with `servers` only when shape is clearly MCP-like. + - TOML with `mcp_servers`. + +## Architecture requirements + +- Represent discovery per client/config source with explicit metadata: + - client kind + - source path or CLI source + - config format + - confidence + - whether writable / eligible for danger rewrite + - discovered servers +- Represent each server with: + - name + - command + - args + - env + - source client/path + - enabled status if known +- Deduplicate identical servers by normalized `(name, command, args, env)`. +- Do not silently overwrite conflicts where the same server name has different command/args/env. Surface conflict and either keep both with deterministic suffixes or require explicit choice. + +## Safe path requirements + +Generate mux-owned files under `~/.config/mux`: + +- `~/.config/mux/config.toml` β€” daemon/upstream truth for `rust-mux`, containing original upstream commands. +- `~/.config/mux/mcp.json` β€” client-facing JSON config where each server command is `rust-mux-proxy`. +- `~/.config/mux/mcp.toml` β€” client-facing TOML config for TOML-style clients / manual Codex merge. + +The generated client-facing configs must point clients at `rust-mux-proxy`, not at upstream MCP servers directly. + +After generation, print concise instructions: + +- how to start `rust-mux` with generated daemon config; +- Claude strict-mode command; +- Junie `--mcp-location` command; +- Codex note that there is no verified strict config-file flag in this environment, plus generated `codex mcp add ...` or manual TOML merge instructions; +- Gemini note using `gemini mcp` commands or danger rewrite. + +## Danger path requirements + +`[DANGER] Automatically configure my clients` must be explicit, backup-first, preview-first, and tested. + +Required behavior: + +- Show scary confirmation text. +- Show dry-run preview of every file and server block to be changed. +- Require explicit confirmation before write. +- Create timestamped backup next to every modified file before mutation. +- Preserve unrelated config keys/tables. +- Modify only selected MCP server blocks. +- If parsing fails, do not modify that file. +- Print rollback commands using the exact backup paths. + +JSON rules: + +- Preserve unknown top-level keys. +- Modify only `mcpServers` or clearly supported `servers` entries. + +TOML rules: + +- Preserve unrelated tables as much as the current TOML library allows. +- If comments cannot be preserved, state that in preview and rely on backup. + +## Tests required + +Add tests proportional to implementation: + +- discover Claude JSON `mcpServers`; +- discover Codex TOML `[mcp_servers.*]`; +- discover Junie JSON config; +- discover generic/custom JSON and TOML; +- Gemini parser only if the actual settings shape is confidently known; otherwise keep it conservative and test generic JSON import; +- deduplicate identical servers; +- detect conflicting server names; +- write mux daemon config + client JSON + client TOML into temp dir; +- danger rewrite creates backup before JSON mutation; +- danger rewrite creates backup before TOML mutation; +- invalid config is not modified. + +Do not require real local user configs in normal CI. Any local-config test must be ignored/optional and read-only. + +## Docs required + +Update `docs/WIZARD.md` and `README.md` if needed: + +- explain the safe default path step by step; +- explain the danger path, backups, rollback, and dry-run; +- document custom imports; +- document per-client commands and limitations; +- include troubleshooting for duplicate names, invalid config, original servers still starting, socket unavailable, and env/secrets handling. + +## Constraints + +- No `#[allow(...)]` suppressions. +- No `// nosemgrep` suppressions. +- No `--no-verify`. +- Do not delete user files. +- Do not rewrite unrelated config content. +- Do not invent unsupported CLI flags. +- Follow existing Rust style and architecture. + +## Acceptance + +- Discovery defaults reflect real Claude, Codex, Junie, Gemini and generic config locations above. +- Wizard offers safe generation and `[DANGER]` automatic client configuration as separate choices. +- Safe path writes mux-owned configs under `~/.config/mux` and does not mutate existing client configs. +- Danger path is backup-first, preview-first, explicit-confirmation-only, and rollback-friendly. +- Tests cover parser/generation/rewrite behavior without relying on local user config. +- Documentation gives exact setup and monitoring guidance. +- Gates pass: + - `cargo fmt -- --check` + - `cargo clippy --all-targets --all-features -- -D warnings` + - `cargo test --all-targets --all-features` + - `make test-full` if available and environment has optional dependencies. + +## Agent roles + +Claude: + +- Audit real-world MCP client config formats and danger-path risks. +- Verify assumptions before changing code. +- Focus on edge cases, data loss risks, rollback quality, and docs truthfulness. + +Gemini: + +- Challenge and simplify the UX/architecture. +- Identify overengineering, unsafe magic, and better command-generation alternatives. +- Keep user flow understandable. + +Codex: + +- Implement the final design after incorporating findings. +- Prioritize exact parser/generator/rewrite code and tests. +- Keep gates green. \ No newline at end of file diff --git a/public/rmcp_mux_icon.png b/public/icon.png similarity index 100% rename from public/rmcp_mux_icon.png rename to public/icon.png diff --git a/src/bin/rmcp_mux_proxy.rs b/src/bin/rust-mux-proxy.rs similarity index 84% rename from src/bin/rmcp_mux_proxy.rs rename to src/bin/rust-mux-proxy.rs index 041a831..9dc35d3 100644 --- a/src/bin/rmcp_mux_proxy.rs +++ b/src/bin/rust-mux-proxy.rs @@ -5,11 +5,11 @@ use clap::Parser; use tokio::io::{self, AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::net::UnixStream; -/// Lightweight STDIO↔Unix-socket proxy for rmcp_mux. +/// Lightweight STDIO↔Unix-socket proxy for rust-mux. #[derive(Parser, Debug)] -#[command(author, version, about = "Proxy STDIO to an rmcp_mux socket")] +#[command(author, version, about = "Proxy STDIO to a rust-mux socket")] struct ProxyCli { - /// Path to the Unix socket exposed by rmcp_mux (e.g. ~/mcp-sockets/memory.sock). + /// Path to the Unix socket exposed by rust-mux (e.g. $HOME/.config/mux/sockets/memory.sock). #[arg(long)] socket: PathBuf, } @@ -47,20 +47,20 @@ where #[cfg(test)] mod tests { use super::run_proxy; - use std::env; use std::path::PathBuf; + use tempfile::{TempDir, tempdir}; use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex}; use tokio::net::UnixListener; - fn socket_path(name: &str) -> PathBuf { - let mut p = env::temp_dir(); - p.push(format!("{name}-{}.sock", uuid::Uuid::new_v4())); - p + fn socket_path(name: &str) -> (TempDir, PathBuf) { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join(format!("{name}.sock")); + (dir, path) } #[tokio::test] async fn proxy_forwards_bytes() { - let path = socket_path("proxy-test"); + let (_dir, path) = socket_path("proxy-test"); let listener = UnixListener::bind(&path).expect("bind socket"); // Echo server task diff --git a/src/bin/rmcp_mux.rs b/src/bin/rust-mux.rs similarity index 84% rename from src/bin/rmcp_mux.rs rename to src/bin/rust-mux.rs index bfc851f..4bce347 100644 --- a/src/bin/rmcp_mux.rs +++ b/src/bin/rust-mux.rs @@ -1,7 +1,7 @@ -//! rmcp_mux CLI binary +//! rust-mux CLI binary //! -//! This is the command-line interface for rmcp_mux. For library usage, -//! see the `rmcp_mux` crate documentation. +//! This is the command-line interface for rust-mux. For library usage, +//! see the `rust_mux` crate documentation. use std::path::PathBuf; @@ -9,15 +9,17 @@ use anyhow::{Result, anyhow}; use clap::{Args, Parser, Subcommand}; use tracing_subscriber::filter::LevelFilter; -use rmcp_mux::config::{ +use tokio_util::sync::CancellationToken; + +use rust_mux::config::{ CliOptions, expand_path, load_config, resolve_params, resolve_params_multi, }; -use rmcp_mux::runtime::{health_check, run_mux, run_proxy}; -use rmcp_mux::scan::{ +use rust_mux::runtime::{health_check, run_mux, run_proxy}; +use rust_mux::scan::{ RewireArgs, ScanArgs, StatusArgs, run_rewire_cmd, run_scan_cmd, run_status_cmd, }; -use rmcp_mux::wizard::WizardArgs; -use rmcp_mux::{ +use rust_mux::wizard::WizardArgs; +use rust_mux::{ DEFAULT_STATUS_SOCKET, print_status_table, query_status, restart_single, run_mux_multi, status_all, }; @@ -39,7 +41,7 @@ enum CliCommand { Wizard(WizardArgs), /// Scan host configs and generate mux manifests/snippets. Scan(ScanArgs), - /// Rewire a host config to point to rmcp_mux proxy. + /// Rewire a host config to point to rust-mux proxy. Rewire(RewireArgs), /// Proxy STDIO to a mux socket (for MCP hosts). Proxy(ProxyArgs), @@ -146,7 +148,7 @@ struct HealthArgs { #[derive(Args, Debug, Clone)] struct DaemonStatusArgs { - /// Status socket path (default: /tmp/rmcp-mux.status.sock) + /// Status socket path (default: /tmp/rust-mux.status.sock) #[arg(long)] socket: Option, /// Output as JSON instead of table @@ -157,18 +159,31 @@ struct DaemonStatusArgs { #[cfg(feature = "tray")] #[derive(Args, Debug, Clone)] struct DashboardArgs { - /// Status socket path (default: /tmp/rmcp-mux.status.sock) + /// Status socket path (default: /tmp/rust-mux.status.sock) #[arg(long)] socket: Option, } -#[tokio::main] -async fn main() -> Result<()> { +fn main() -> Result<()> { let cli = RootCli::parse(); + // Handle Dashboard command BEFORE starting tokio runtime (macOS requires main thread) + #[cfg(feature = "tray")] + if let Some(CliCommand::Dashboard(args)) = &cli.command { + return run_dashboard(args.clone()); + } + + // Run async main for all other commands + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async_main(cli)) +} + +async fn async_main(cli: RootCli) -> Result<()> { match &cli.command { Some(CliCommand::Wizard(wargs)) => { - rmcp_mux::wizard::run_wizard(wargs.clone()).await?; + rust_mux::wizard::run_wizard(wargs.clone()).await?; return Ok(()); } Some(CliCommand::Scan(args)) => { @@ -194,6 +209,11 @@ async fn main() -> Result<()> { run_daemon_status(args.clone()).await?; return Ok(()); } + #[cfg(feature = "tray")] + Some(CliCommand::Dashboard(_)) => { + // Already handled in main() before tokio starts + unreachable!("Dashboard handled before async runtime"); + } None => {} } @@ -255,7 +275,8 @@ async fn main() -> Result<()> { "starting mux in multi-service mode" ); - run_mux_multi(params_list).await + let shutdown = CancellationToken::new(); + run_mux_multi(params_list, shutdown).await } else { // Single service mode (legacy behavior) let params = resolve_params(&cli, config.as_ref())?; @@ -279,7 +300,8 @@ async fn main() -> Result<()> { "mux starting" ); - run_mux(params).await + let shutdown = CancellationToken::new(); + run_mux(params, shutdown).await } } @@ -302,7 +324,7 @@ async fn run_daemon_status(args: DaemonStatusArgs) -> Result<()> { let status = query_status(&socket).await.map_err(|e| { anyhow!( - "failed to connect to mux daemon at {}: {} (is rmcp-mux running?)", + "failed to connect to mux daemon at {}: {} (is rust-mux running?)", socket.display(), e ) @@ -317,6 +339,23 @@ async fn run_daemon_status(args: DaemonStatusArgs) -> Result<()> { Ok(()) } +#[cfg(feature = "tray")] +fn run_dashboard(args: DashboardArgs) -> Result<()> { + use tokio_util::sync::CancellationToken; + + let shutdown = CancellationToken::new(); + + println!("Starting rust-mux dashboard..."); + println!("Click 'Quit Dashboard' in tray menu to exit"); + + let icon = rust_mux::tray::find_tray_icon(); + // Run on main thread - required for macOS tray menu creation + rust_mux::tray_dashboard::run_tray_dashboard(shutdown, icon, args.socket); + + println!("Dashboard closed"); + Ok(()) +} + // Implement CliOptions trait for the Cli struct impl CliOptions for Cli { fn socket(&self) -> Option { diff --git a/src/config.rs b/src/config.rs index 006c3d3..f1ba8b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,57 +1,24 @@ -//! Configuration types and loading for rmcp_mux. - use std::collections::HashMap; use std::fs; +use std::fs::File; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; -/// Sanitize a file path by canonicalizing it to prevent path traversal. -/// Returns the canonicalized path if it exists and is valid. -pub fn sanitize_path(path: &Path) -> Result { - // Canonicalize resolves symlinks and .. components - let canonical = fs::canonicalize(path) - .with_context(|| format!("failed to resolve path: {}", path.display()))?; - Ok(canonical) -} - -/// Read file contents after sanitizing the path. -pub fn safe_read_to_string(path: &Path) -> Result { - let safe_path = sanitize_path(path)?; - // Path is already canonicalized above, resolving symlinks and .. components - fs::read_to_string(&safe_path) // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path - .with_context(|| format!("failed to read file: {}", safe_path.display())) -} - -/// Copy file after sanitizing paths. -pub fn safe_copy(from: &Path, to: &Path) -> Result { - let safe_from = sanitize_path(from)?; - // For destination, parent must exist but file may not yet - let to_parent = to - .parent() - .ok_or_else(|| anyhow!("invalid destination path"))?; - let _ = sanitize_path(to_parent)?; - // Source and dest parent validated via canonicalize - #[allow(clippy::let_and_return)] - let bytes = fs::copy(&safe_from, to)?; // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path - Ok(bytes) -} - -/// Multi-server configuration file format. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct Config { pub servers: HashMap, } -/// Per-service configuration in the config file. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerConfig { pub socket: Option, pub cmd: Option, pub args: Option>, - pub env: Option>, + pub cwd: Option, pub max_active_clients: Option, pub tray: Option, pub service_name: Option, @@ -63,72 +30,37 @@ pub struct ServerConfig { pub restart_backoff_max_ms: Option, pub max_restarts: Option, pub status_file: Option, - /// Interval between heartbeat probes in milliseconds (default: 30000). + pub env: Option>, pub heartbeat_interval_ms: Option, - /// Timeout for heartbeat response in milliseconds (default: 30000). pub heartbeat_timeout_ms: Option, - /// Number of consecutive heartbeat failures before triggering restart (default: 3). pub heartbeat_max_failures: Option, - /// Enable/disable heartbeat monitoring (default: true). pub heartbeat_enabled: Option, } -/// Resolved runtime parameters for the mux daemon. #[derive(Clone, Debug)] pub struct ResolvedParams { pub socket: PathBuf, pub cmd: String, pub args: Vec, - pub env: HashMap, + pub cwd: Option, pub max_clients: usize, pub tray_enabled: bool, pub log_level: String, pub service_name: String, pub lazy_start: bool, pub max_request_bytes: usize, - pub request_timeout: Duration, - pub restart_backoff: Duration, - pub restart_backoff_max: Duration, + pub request_timeout: std::time::Duration, + pub restart_backoff: std::time::Duration, + pub restart_backoff_max: std::time::Duration, pub max_restarts: u64, pub status_file: Option, - /// Heartbeat probe interval. + pub env: Option>, pub heartbeat_interval: Duration, - /// Heartbeat response timeout. pub heartbeat_timeout: Duration, - /// Max consecutive heartbeat failures before restart. pub heartbeat_max_failures: u32, - /// Whether heartbeat monitoring is enabled. pub heartbeat_enabled: bool, } -/// CLI options that can override config file settings. -/// -/// This trait allows the binary to pass CLI arguments to resolve_params -/// without the library depending on clap types. -pub trait CliOptions { - fn socket(&self) -> Option; - fn cmd(&self) -> Option; - fn args(&self) -> Vec; - fn max_active_clients(&self) -> usize; - fn lazy_start(&self) -> Option; - fn max_request_bytes(&self) -> Option; - fn request_timeout_ms(&self) -> Option; - fn restart_backoff_ms(&self) -> Option; - fn restart_backoff_max_ms(&self) -> Option; - fn max_restarts(&self) -> Option; - fn log_level(&self) -> String; - fn tray(&self) -> bool; - fn service_name(&self) -> Option; - fn service(&self) -> Option; - fn status_file(&self) -> Option; - fn heartbeat_interval_ms(&self) -> Option; - fn heartbeat_timeout_ms(&self) -> Option; - fn heartbeat_max_failures(&self) -> Option; - fn heartbeat_enabled(&self) -> Option; - fn only(&self) -> Option>; - fn except(&self) -> Option>; -} - pub fn expand_path(raw: impl AsRef) -> PathBuf { let s = raw.as_ref(); if let Some(stripped) = s.strip_prefix("~/") @@ -139,11 +71,89 @@ pub fn expand_path(raw: impl AsRef) -> PathBuf { PathBuf::from(s) } +fn reject_parent_components(path: &Path) -> Result<()> { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(anyhow!( + "refusing path with parent traversal component: {}", + path.display() + )); + } + Ok(()) +} + +pub fn vetted_existing_file(path: &Path) -> Result { + reject_parent_components(path)?; + let canonical = fs::canonicalize(path) + .with_context(|| format!("failed to canonicalize {}", path.display()))?; + if !canonical.is_file() { + return Err(anyhow!("not a regular file: {}", canonical.display())); + } + Ok(canonical) +} + +fn vetted_output_file(path: &Path) -> Result { + reject_parent_components(path)?; + let parent = path + .parent() + .ok_or_else(|| anyhow!("output path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + let safe_parent = fs::canonicalize(parent) + .with_context(|| format!("failed to canonicalize {}", parent.display()))?; + if !safe_parent.is_dir() { + return Err(anyhow!( + "output parent is not a directory: {}", + safe_parent.display() + )); + } + let name = path + .file_name() + .ok_or_else(|| anyhow!("output path has no file name: {}", path.display()))?; + Ok(safe_parent.join(name)) +} + +pub fn safe_read_to_string(path: &Path) -> Result { + let safe_path = vetted_existing_file(path)?; + let mut data = String::new(); + // `safe_path` is canonicalized, must be a regular file, and rejects `..`. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let mut input = File::open(&safe_path) + .with_context(|| format!("failed to open {}", safe_path.display()))?; + input + .read_to_string(&mut data) + .with_context(|| format!("failed to read {}", safe_path.display()))?; + Ok(data) +} + +pub fn safe_copy_file(src: &Path, dst: &Path) -> Result<()> { + let safe_src = vetted_existing_file(src)?; + let safe_dst = vetted_output_file(dst)?; + // Both paths have passed the canonical file/output boundary above. + let mut input = File::open(&safe_src) // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + .with_context(|| format!("failed to open {}", safe_src.display()))?; + // `safe_dst` is anchored under a canonicalized directory and rejects `..`. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let mut output = File::create(&safe_dst) + .with_context(|| format!("failed to create {}", safe_dst.display()))?; + io::copy(&mut input, &mut output).with_context(|| { + format!( + "failed to copy {} to {}", + safe_src.display(), + safe_dst.display() + ) + })?; + Ok(()) +} + pub fn load_config(path: &Path) -> Result> { if !path.exists() { return Ok(None); } - let data = safe_read_to_string(path)?; + let data = safe_read_to_string(path) + .with_context(|| format!("failed to read config at {}", path.display()))?; let ext = path .extension() @@ -162,18 +172,139 @@ pub fn load_config(path: &Path) -> Result> { Ok(Some(cfg)) } -/// Resolve runtime parameters from CLI options and config file. -/// -/// CLI options take precedence over config file settings. -pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result { +pub fn safe_copy(src: &Path, dst: &Path) -> Result<()> { + safe_copy_file(src, dst) +} + +pub trait CliOptions { + fn socket(&self) -> Option; + fn cmd(&self) -> Option; + fn args(&self) -> Vec; + fn max_active_clients(&self) -> usize; + fn lazy_start(&self) -> Option; + fn max_request_bytes(&self) -> Option; + fn request_timeout_ms(&self) -> Option; + fn restart_backoff_ms(&self) -> Option; + fn restart_backoff_max_ms(&self) -> Option; + fn max_restarts(&self) -> Option; + fn log_level(&self) -> String; + fn tray(&self) -> bool; + fn service_name(&self) -> Option; + fn service(&self) -> Option; + fn status_file(&self) -> Option; + fn heartbeat_interval_ms(&self) -> Option; + fn heartbeat_timeout_ms(&self) -> Option; + fn heartbeat_max_failures(&self) -> Option; + fn heartbeat_enabled(&self) -> Option; + fn only(&self) -> Option>; + fn except(&self) -> Option>; +} + +pub fn resolve_params_multi(cli: &dyn CliOptions, config: &Config) -> Result> { + let mut results = Vec::new(); + let only = cli.only(); + let except = cli.except(); + + for name in config.servers.keys() { + if let Some(only_list) = &only + && !only_list.contains(name) + { + continue; + } + if let Some(except_list) = &except + && except_list.contains(name) + { + continue; + } + + // Create a temporary CLI-like object for single resolution + struct SingleCli<'a> { + parent: &'a dyn CliOptions, + service: String, + } + impl<'a> CliOptions for SingleCli<'a> { + fn socket(&self) -> Option { + self.parent.socket() + } + fn cmd(&self) -> Option { + self.parent.cmd() + } + fn args(&self) -> Vec { + self.parent.args() + } + fn max_active_clients(&self) -> usize { + self.parent.max_active_clients() + } + fn lazy_start(&self) -> Option { + self.parent.lazy_start() + } + fn max_request_bytes(&self) -> Option { + self.parent.max_request_bytes() + } + fn request_timeout_ms(&self) -> Option { + self.parent.request_timeout_ms() + } + fn restart_backoff_ms(&self) -> Option { + self.parent.restart_backoff_ms() + } + fn restart_backoff_max_ms(&self) -> Option { + self.parent.restart_backoff_max_ms() + } + fn max_restarts(&self) -> Option { + self.parent.max_restarts() + } + fn log_level(&self) -> String { + self.parent.log_level() + } + fn tray(&self) -> bool { + self.parent.tray() + } + fn service_name(&self) -> Option { + self.parent.service_name() + } + fn service(&self) -> Option { + Some(self.service.clone()) + } + fn status_file(&self) -> Option { + self.parent.status_file() + } + fn heartbeat_interval_ms(&self) -> Option { + self.parent.heartbeat_interval_ms() + } + fn heartbeat_timeout_ms(&self) -> Option { + self.parent.heartbeat_timeout_ms() + } + fn heartbeat_max_failures(&self) -> Option { + self.parent.heartbeat_max_failures() + } + fn heartbeat_enabled(&self) -> Option { + self.parent.heartbeat_enabled() + } + fn only(&self) -> Option> { + None + } + fn except(&self) -> Option> { + None + } + } + + let single = SingleCli { + parent: cli, + service: name.clone(), + }; + results.push(resolve_params(&single, Some(config))?); + } + Ok(results) +} +pub fn resolve_params(cli: &dyn CliOptions, config: Option<&Config>) -> Result { let service_cfg = if let Some(cfg) = config { - if let Some(name) = cli.service() { + if let Some(name) = &cli.service() { let found = cfg .servers - .get(&name) + .get(name) .cloned() .ok_or_else(|| anyhow!("service '{name}' not found in config"))?; - Some((name, found)) + Some((name.clone(), found)) } else { None } @@ -187,6 +318,7 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result let socket = cli .socket() + .clone() .or_else(|| { service_cfg .as_ref() @@ -196,12 +328,12 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result let cmd = cli .cmd() + .clone() .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.cmd.clone())) .ok_or_else(|| anyhow!("cmd not provided (use --cmd or config)"))?; - let cli_args = cli.args(); - let args = if !cli_args.is_empty() { - cli_args + let args = if !cli.args().is_empty() { + cli.args().clone() } else { service_cfg .as_ref() @@ -209,10 +341,9 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result .unwrap_or_default() }; - let env = service_cfg + let cwd = service_cfg .as_ref() - .and_then(|(_, c)| c.env.clone()) - .unwrap_or_default(); + .and_then(|(_, c)| c.cwd.as_deref().map(expand_path)); let max_clients = service_cfg .as_ref() @@ -231,7 +362,7 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result let log_level = service_cfg .as_ref() .and_then(|(_, c)| c.log_level.clone()) - .unwrap_or_else(|| cli.log_level()); + .unwrap_or_else(|| cli.log_level().clone()); let lazy_start = cli.lazy_start().unwrap_or_else(|| { service_cfg @@ -273,17 +404,17 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.max_restarts)) .unwrap_or(5); - let status_file = cli - .status_file() - .map(|p| p.to_str().map(expand_path).unwrap_or_else(|| p.clone())) - .or_else(|| { - service_cfg - .as_ref() - .and_then(|(_, c)| c.status_file.as_deref().map(expand_path)) - }); + let status_file = cli.status_file().clone().or_else(|| { + service_cfg + .as_ref() + .and_then(|(_, c)| c.status_file.as_deref().map(expand_path)) + }); + + let env = service_cfg.as_ref().and_then(|(_, c)| c.env.clone()); let service_name_raw = cli .service_name() + .clone() .or_else(|| { service_cfg .as_ref() @@ -294,7 +425,7 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result .file_name() .and_then(|n| n.to_string_lossy().split('.').next().map(|s| s.to_string())) }) - .unwrap_or_else(|| "rmcp_mux".to_string()); + .unwrap_or_else(|| "rust_mux".to_string()); // Heartbeat configuration let heartbeat_interval = Duration::from_millis( @@ -332,7 +463,7 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result socket, cmd, args, - env, + cwd, max_clients, tray_enabled, log_level, @@ -344,6 +475,7 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result restart_backoff_max, max_restarts, status_file, + env, heartbeat_interval, heartbeat_timeout, heartbeat_max_failures, @@ -351,126 +483,38 @@ pub fn resolve_params(cli: &C, config: Option<&Config>) -> Result }) } -/// Resolve parameters for multiple services from config file. -/// -/// Applies --only and --except filters to select which servers to run. -/// Returns a list of ResolvedParams, one per selected service. -pub fn resolve_params_multi( - cli: &C, - config: &Config, -) -> Result> { - let only_filter = cli.only(); - let except_filter = cli.except(); +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; - let mut results = Vec::new(); + #[test] + fn safe_read_rejects_parent_traversal() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("config.json"); + fs::write(&file, "{}").expect("write"); + let traversing = dir.path().join("nested/../config.json"); - for (name, server_cfg) in &config.servers { - // Apply filters - if let Some(ref only) = only_filter - && !only.contains(name) - { - continue; - } - if let Some(ref except) = except_filter - && except.contains(name) - { - continue; - } + let err = safe_read_to_string(&traversing).expect_err("must reject parent traversal"); - // Build resolved params for this service - let socket = server_cfg - .socket - .as_ref() - .map(expand_path) - .ok_or_else(|| anyhow!("service '{}' missing socket in config", name))?; - - let cmd = server_cfg - .cmd - .clone() - .ok_or_else(|| anyhow!("service '{}' missing cmd in config", name))?; - - let args = server_cfg.args.clone().unwrap_or_default(); - let env = server_cfg.env.clone().unwrap_or_default(); - let max_clients = server_cfg - .max_active_clients - .unwrap_or(cli.max_active_clients()); - let tray_enabled = server_cfg.tray.unwrap_or(cli.tray()); - let log_level = server_cfg - .log_level - .clone() - .unwrap_or_else(|| cli.log_level()); - let lazy_start = cli.lazy_start().or(server_cfg.lazy_start).unwrap_or(false); - let max_request_bytes = cli - .max_request_bytes() - .or(server_cfg.max_request_bytes) - .unwrap_or(1_048_576); - let request_timeout = Duration::from_millis( - cli.request_timeout_ms() - .or(server_cfg.request_timeout_ms) - .unwrap_or(30_000), - ); - let restart_backoff = Duration::from_millis( - cli.restart_backoff_ms() - .or(server_cfg.restart_backoff_ms) - .unwrap_or(1_000), - ); - let restart_backoff_max = Duration::from_millis( - cli.restart_backoff_max_ms() - .or(server_cfg.restart_backoff_max_ms) - .unwrap_or(30_000), + assert!( + err.to_string().contains("parent traversal"), + "unexpected error: {err}" ); - let max_restarts = cli.max_restarts().or(server_cfg.max_restarts).unwrap_or(5); - let status_file = cli - .status_file() - .map(|p| p.to_str().map(expand_path).unwrap_or_else(|| p.clone())) - .or_else(|| server_cfg.status_file.as_deref().map(expand_path)); - - let service_name = server_cfg - .service_name - .clone() - .unwrap_or_else(|| name.clone()); - - let heartbeat_interval = Duration::from_millis( - cli.heartbeat_interval_ms() - .or(server_cfg.heartbeat_interval_ms) - .unwrap_or(30_000), - ); - let heartbeat_timeout = Duration::from_millis( - cli.heartbeat_timeout_ms() - .or(server_cfg.heartbeat_timeout_ms) - .unwrap_or(30_000), - ); - let heartbeat_max_failures = cli - .heartbeat_max_failures() - .or(server_cfg.heartbeat_max_failures) - .unwrap_or(3); - let heartbeat_enabled = cli - .heartbeat_enabled() - .or(server_cfg.heartbeat_enabled) - .unwrap_or(true); - - results.push(ResolvedParams { - socket, - cmd, - args, - env, - max_clients, - tray_enabled, - log_level, - service_name, - lazy_start, - max_request_bytes, - request_timeout, - restart_backoff, - restart_backoff_max, - max_restarts, - status_file, - heartbeat_interval, - heartbeat_timeout, - heartbeat_max_failures, - heartbeat_enabled, - }); } - Ok(results) + #[test] + fn safe_copy_rejects_parent_traversal_destination() { + let dir = tempdir().expect("tempdir"); + let src = dir.path().join("config.json"); + fs::write(&src, "{}").expect("write"); + let dst = dir.path().join("backup/../config.bak"); + + let err = safe_copy_file(&src, &dst).expect_err("must reject parent traversal"); + + assert!( + err.to_string().contains("parent traversal"), + "unexpected error: {err}" + ); + } } diff --git a/src/danger.rs b/src/danger.rs new file mode 100644 index 0000000..6f131e1 --- /dev/null +++ b/src/danger.rs @@ -0,0 +1,878 @@ +//! `[DANGER]` automatic client configuration. +//! +//! This module rewrites the user's *existing* MCP client configs so each +//! known server starts `rust-mux-proxy` instead of the upstream command. +//! It does so in a backup-first, preview-first, explicit-confirmation-only +//! way: +//! +//! 1. `plan_danger_rewrite` inspects every eligible source, computes the +//! change, and assembles a [`DangerPlan`] with one [`DangerAction`] per +//! file. Sources that fail to parse are recorded as actions with +//! `status = SkippedInvalid` β€” they are *never* mutated. +//! 2. `format_preview` turns the plan into human-readable text the wizard +//! shows before any disk write. +//! 3. `execute_plan` is the only function that touches disk: it requires +//! `confirmed = true`, copies each target file to a timestamped +//! `..bak`, then writes the rewritten content. If +//! the timestamped backup already exists it falls back to a +//! `.-.bak` form so two runs in the same second +//! never collide. +//! +//! JSON rules: only the `mcpServers` (or `servers`) entries we actually +//! discovered are replaced. All other top-level keys are preserved exactly. +//! +//! TOML rules: only the `mcp_servers` table is replaced. All other tables +//! are preserved at the value level. Comments and key order are not +//! preserved β€” the preview always says so and the backup is the source of +//! truth for rollback. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; +use serde::Serialize; + +use crate::config::{safe_copy_file, safe_read_to_string}; +use crate::scan::{ + ConfigSchema, HostFile, HostFormat, HostKind, HostService, ScanResult, scan_host_file, +}; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum DangerStatus { + /// Source parses cleanly and a rewrite has been planned. + Planned, + /// Source could not be parsed; rewrite is skipped (file untouched). + SkippedInvalid, + /// Source is ineligible for the danger flow (e.g. Gemini settings). + SkippedIneligible, + /// No services to rewrite for this source. + SkippedEmpty, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DangerAction { + pub source: HostFile, + pub status: DangerStatus, + /// Rewritten file contents. `None` when status != Planned. + pub new_contents: Option, + /// One-line per-server change summary lines (for preview). + pub change_lines: Vec, + /// Reason the source was skipped, when applicable. + pub skip_reason: Option, + /// Existing services we were about to rewrite (for preview). + pub existing_services: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DangerPlan { + pub actions: Vec, + pub proxy_cmd: String, + pub proxy_args: Vec, + pub socket_dir: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DangerExecutionOutcome { + pub path: PathBuf, + pub backup: Option, + pub written: bool, + pub status: DangerStatus, + pub error: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plan +// ───────────────────────────────────────────────────────────────────────────── + +/// Plan a danger rewrite for the given list of eligible sources. Sources +/// flagged as `eligible_for_danger = false` are recorded as +/// `SkippedIneligible` and never planned for mutation. +pub fn plan_danger_rewrite( + sources: &[HostFile], + proxy_cmd: &str, + proxy_args: &[String], + socket_dir: &Path, +) -> DangerPlan { + let mut actions = Vec::with_capacity(sources.len()); + for source in sources { + if !source.eligible_for_danger { + actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::SkippedIneligible, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some(format!( + "{} has no verified strict-config flag; use the safe path instructions instead", + source.kind.display_name() + )), + existing_services: Vec::new(), + }); + continue; + } + + let scan = match scan_host_file(source) { + Ok(s) => s, + Err(err) => { + actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::SkippedInvalid, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some(format!("parse error: {err}")), + existing_services: Vec::new(), + }); + continue; + } + }; + + if scan.services.is_empty() { + push_empty_action(&mut actions, source.clone()); + continue; + } + + push_rewrite_action(&mut actions, scan, proxy_cmd, proxy_args, socket_dir); + } + + DangerPlan { + actions, + proxy_cmd: proxy_cmd.to_string(), + proxy_args: proxy_args.to_vec(), + socket_dir: socket_dir.to_path_buf(), + } +} + +/// Plan danger rewrites from already-scanned sources. +/// +/// The wizard uses this after STEP 2 so operator deselections are preserved: +/// callers can filter `scan.services` before planning, while unknown or +/// unselected entries in the original file remain untouched by the rewriter. +pub fn plan_danger_rewrite_for_scans( + scans: &[ScanResult], + proxy_cmd: &str, + proxy_args: &[String], + socket_dir: &Path, +) -> DangerPlan { + let mut actions = Vec::with_capacity(scans.len()); + for scan in scans { + let source = &scan.host; + if !source.eligible_for_danger { + actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::SkippedIneligible, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some(format!( + "{} has no verified strict-config flag; use the safe path instructions instead", + source.kind.display_name() + )), + existing_services: scan.services.clone(), + }); + continue; + } + + if scan.services.is_empty() { + push_empty_action(&mut actions, source.clone()); + continue; + } + + push_rewrite_action( + &mut actions, + scan.clone(), + proxy_cmd, + proxy_args, + socket_dir, + ); + } + + DangerPlan { + actions, + proxy_cmd: proxy_cmd.to_string(), + proxy_args: proxy_args.to_vec(), + socket_dir: socket_dir.to_path_buf(), + } +} + +fn push_empty_action(actions: &mut Vec, source: HostFile) { + actions.push(DangerAction { + source, + status: DangerStatus::SkippedEmpty, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some( + "no selected MCP servers found in this config; nothing to rewrite".into(), + ), + existing_services: Vec::new(), + }); +} + +fn push_rewrite_action( + actions: &mut Vec, + scan: ScanResult, + proxy_cmd: &str, + proxy_args: &[String], + socket_dir: &Path, +) { + let source = &scan.host; + let original = match safe_read_to_string(&source.path) { + Ok(s) => s, + Err(err) => { + actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::SkippedInvalid, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some(format!("read error: {err}")), + existing_services: Vec::new(), + }); + return; + } + }; + + let rewrite = match source.format { + HostFormat::Json => rewrite_json_keep_other_keys( + &original, + &source.path, + source.schema, + &scan.services, + proxy_cmd, + proxy_args, + socket_dir, + ), + HostFormat::Toml => rewrite_toml_keep_other_tables( + &original, + &source.path, + source.kind, + &scan.services, + proxy_cmd, + proxy_args, + socket_dir, + ), + }; + + match rewrite { + Ok((new_contents, change_lines)) => actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::Planned, + new_contents: Some(new_contents), + change_lines, + skip_reason: None, + existing_services: scan.services, + }), + Err(err) => actions.push(DangerAction { + source: source.clone(), + status: DangerStatus::SkippedInvalid, + new_contents: None, + change_lines: Vec::new(), + skip_reason: Some(format!("rewrite failed: {err}")), + existing_services: scan.services, + }), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Preview +// ───────────────────────────────────────────────────────────────────────────── + +/// Render a human-readable preview of the plan. The wizard shows this before +/// asking for confirmation; nothing has been written to disk yet. +pub fn format_preview(plan: &DangerPlan) -> String { + let mut out = String::new(); + out.push_str("⚠️ [DANGER] Automatic client configuration plan\n"); + out.push_str("====================================================\n"); + out.push_str(&format!("Proxy command : {}\n", plan.proxy_cmd)); + if !plan.proxy_args.is_empty() { + out.push_str(&format!("Proxy pre-args: {}\n", plan.proxy_args.join(" "))); + } + out.push_str(&format!( + "Socket dir : {}\n\n", + plan.socket_dir.display() + )); + + let planned: Vec<&DangerAction> = plan + .actions + .iter() + .filter(|a| a.status == DangerStatus::Planned) + .collect(); + let skipped: Vec<&DangerAction> = plan + .actions + .iter() + .filter(|a| a.status != DangerStatus::Planned) + .collect(); + + out.push_str(&format!("Planned changes ({} file(s)):\n", planned.len())); + for action in &planned { + out.push_str(&format!( + " β€’ {} ({} format, {} services)\n", + action.source.path.display(), + match action.source.format { + HostFormat::Json => "JSON", + HostFormat::Toml => "TOML", + }, + action.existing_services.len() + )); + for change in &action.change_lines { + out.push_str(&format!(" - {change}\n")); + } + out.push_str(&format!( + " backup target: {}..bak\n", + action.source.path.display() + )); + } + + if !skipped.is_empty() { + out.push_str(&format!("\nSkipped ({}):\n", skipped.len())); + for action in skipped { + let reason = action.skip_reason.as_deref().unwrap_or("(unspecified)"); + out.push_str(&format!( + " β€’ {}: {} ({})\n", + action.source.path.display(), + short_status(&action.status), + reason + )); + } + } + + out.push_str("\nNotes:\n"); + out.push_str(" - Every modified file gets a timestamped .bak next to it before mutation.\n"); + out.push_str(" - TOML rewrites lose comments and key order; .bak preserves the original.\n"); + out.push_str(" - Files with parse errors are NEVER modified.\n"); + out.push_str(" - Unrelated config keys/tables are preserved.\n"); + out +} + +fn short_status(status: &DangerStatus) -> &'static str { + match status { + DangerStatus::Planned => "planned", + DangerStatus::SkippedInvalid => "invalid", + DangerStatus::SkippedIneligible => "ineligible", + DangerStatus::SkippedEmpty => "empty", + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Execution +// ───────────────────────────────────────────────────────────────────────────── + +/// Execute the plan. Requires `confirmed = true`; without it the call is a +/// no-op that returns an explicit error so accidental dispatches cannot +/// silently mutate user files. +pub fn execute_plan(plan: &DangerPlan, confirmed: bool) -> Result> { + if !confirmed { + return Err(anyhow!( + "danger plan execution refused: explicit confirmation required" + )); + } + let mut outcomes = Vec::with_capacity(plan.actions.len()); + for action in &plan.actions { + match (&action.status, action.new_contents.as_deref()) { + (DangerStatus::Planned, Some(new_contents)) => { + let res = write_with_timestamped_backup(&action.source.path, new_contents); + match res { + Ok(backup) => outcomes.push(DangerExecutionOutcome { + path: action.source.path.clone(), + backup: Some(backup), + written: true, + status: DangerStatus::Planned, + error: None, + }), + Err(err) => outcomes.push(DangerExecutionOutcome { + path: action.source.path.clone(), + backup: None, + written: false, + status: DangerStatus::SkippedInvalid, + error: Some(err.to_string()), + }), + } + } + _ => { + outcomes.push(DangerExecutionOutcome { + path: action.source.path.clone(), + backup: None, + written: false, + status: action.status.clone(), + error: action.skip_reason.clone(), + }); + } + } + } + Ok(outcomes) +} + +/// Rollback hint lines for the operator: the exact `cp` commands to restore +/// each backed-up file to its previous state. +pub fn rollback_commands(outcomes: &[DangerExecutionOutcome]) -> Vec { + outcomes + .iter() + .filter_map(|o| { + o.backup + .as_ref() + .map(|b| format!("cp -p {} {}", shell_quote(b), shell_quote(&o.path))) + }) + .collect() +} + +fn shell_quote(p: &Path) -> String { + let s = p.display().to_string(); + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | '~')) + { + s + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +fn write_with_timestamped_backup(path: &Path, contents: &str) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut backup = backup_path(path, secs, None); + let mut counter = 1u32; + while backup.exists() { + backup = backup_path(path, secs, Some(counter)); + counter += 1; + if counter > 1000 { + return Err(anyhow!( + "could not find unique backup path for {}", + path.display() + )); + } + } + + if path.exists() { + safe_copy_file(path, &backup) + .with_context(|| format!("failed to create backup {}", backup.display()))?; + } + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?; + Ok(backup) +} + +fn backup_path(path: &Path, secs: u64, suffix: Option) -> PathBuf { + let mut name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "danger".to_string()); + match suffix { + Some(n) => name.push_str(&format!(".{secs}-{n}.bak")), + None => name.push_str(&format!(".{secs}.bak")), + } + let mut p = path.to_path_buf(); + p.set_file_name(name); + p +} + +// ───────────────────────────────────────────────────────────────────────────── +// Format-specific surgical rewrites +// ───────────────────────────────────────────────────────────────────────────── + +fn rewrite_json_keep_other_keys( + original: &str, + path: &Path, + schema: ConfigSchema, + services: &[HostService], + proxy_cmd: &str, + proxy_args: &[String], + socket_dir: &Path, +) -> Result<(String, Vec)> { + let mut root: serde_json::Value = serde_json::from_str(original) + .with_context(|| format!("failed to parse json {}", path.display()))?; + let obj = root + .as_object_mut() + .ok_or_else(|| anyhow!("{}: top-level JSON must be an object", path.display()))?; + + // Determine which key to update: schema's stated key, or whichever exists. + let key = match schema { + ConfigSchema::McpServersJson => "mcpServers", + ConfigSchema::ServersJson => "servers", + ConfigSchema::AutoJson => { + if obj.contains_key("mcpServers") { + "mcpServers" + } else if obj.contains_key("servers") { + "servers" + } else { + "mcpServers" + } + } + ConfigSchema::McpServersToml => "mcpServers", + }; + + let existing = obj + .get(key) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let mut change_lines = Vec::with_capacity(services.len()); + let mut new_map = serde_json::Map::new(); + + // Preserve any entries that we did NOT discover as MCP-shaped (best-effort + // defensive behaviour: don't drop unfamiliar keys under mcpServers). + let discovered_names: std::collections::HashSet<&str> = + services.iter().map(|s| s.name.as_str()).collect(); + for (name, value) in &existing { + if !discovered_names.contains(name.as_str()) { + new_map.insert(name.clone(), value.clone()); + } + } + + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + let mut args: Vec = proxy_args.to_owned(); + args.push("--socket".to_string()); + args.push(socket); + + let mut server = serde_json::Map::new(); + server.insert( + "command".to_string(), + serde_json::Value::String(proxy_cmd.to_string()), + ); + server.insert( + "args".to_string(), + serde_json::Value::Array(args.into_iter().map(serde_json::Value::String).collect()), + ); + if let Some(env) = &svc.env { + server.insert( + "env".to_string(), + serde_json::Value::Object( + env.iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(), + ), + ); + } + + change_lines.push(format!( + "rewrite `{}`: {} -> {}", + svc.name, svc.command, proxy_cmd + )); + new_map.insert(svc.name.clone(), serde_json::Value::Object(server)); + } + + obj.insert(key.into(), serde_json::Value::Object(new_map)); + let serialized = serde_json::to_string_pretty(&root).context("serialize rewritten json")?; + Ok((serialized, change_lines)) +} + +fn rewrite_toml_keep_other_tables( + original: &str, + path: &Path, + kind: HostKind, + services: &[HostService], + proxy_cmd: &str, + proxy_args: &[String], + socket_dir: &Path, +) -> Result<(String, Vec)> { + let mut root: toml::Value = toml::from_str(original) + .with_context(|| format!("failed to parse toml {}", path.display()))?; + let table = root + .as_table_mut() + .ok_or_else(|| anyhow!("{}: top-level TOML must be a table", path.display()))?; + + let target_key = match kind { + HostKind::Codex => "mcp_servers", + _ => "mcp_servers", + }; + + let existing = table + .get(target_key) + .and_then(|v| v.as_table()) + .cloned() + .unwrap_or_default(); + + let discovered_names: std::collections::HashSet<&str> = + services.iter().map(|s| s.name.as_str()).collect(); + let mut new_table = toml::value::Table::new(); + for (name, value) in &existing { + if !discovered_names.contains(name.as_str()) { + new_table.insert(name.clone(), value.clone()); + } + } + + let mut change_lines = Vec::with_capacity(services.len()); + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + let mut args: Vec = proxy_args.to_owned(); + args.push("--socket".to_string()); + args.push(socket); + + let mut entry = toml::value::Table::new(); + entry.insert("command".into(), toml::Value::String(proxy_cmd.to_string())); + entry.insert( + "args".into(), + toml::Value::Array(args.into_iter().map(toml::Value::String).collect()), + ); + if let Some(env) = &svc.env { + let env_map: HashMap = env + .iter() + .map(|(k, v)| (k.clone(), toml::Value::String(v.clone()))) + .collect(); + let mut env_table = toml::value::Table::new(); + for (k, v) in env_map { + env_table.insert(k, v); + } + entry.insert("env".into(), toml::Value::Table(env_table)); + } + + change_lines.push(format!( + "rewrite `{}`: {} -> {}", + svc.name, svc.command, proxy_cmd + )); + new_table.insert(svc.name.clone(), toml::Value::Table(entry)); + } + + table.insert(target_key.into(), toml::Value::Table(new_table)); + let serialized = toml::to_string_pretty(&root).context("serialize rewritten toml")?; + Ok((serialized, change_lines)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat, HostKind}; + use tempfile::tempdir; + + fn write_text(path: &Path, body: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + fs::write(path, body).expect("write"); + } + + fn json_source(path: PathBuf, kind: HostKind) -> HostFile { + HostFile { + kind, + path, + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + fn toml_source(path: PathBuf, kind: HostKind) -> HostFile { + HostFile { + kind, + path, + format: HostFormat::Toml, + schema: ConfigSchema::McpServersToml, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + #[test] + fn execute_refuses_without_confirmation() { + let plan = DangerPlan { + actions: Vec::new(), + proxy_cmd: "rust-mux-proxy".into(), + proxy_args: Vec::new(), + socket_dir: PathBuf::from("/tmp"), + }; + let res = execute_plan(&plan, false); + assert!( + res.is_err(), + "execute_plan should refuse without confirmation" + ); + } + + #[test] + fn json_rewrite_creates_timestamped_backup_and_keeps_other_keys() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("claude.json"); + write_text( + &path, + r#"{ + "trustedTools": ["one", "two"], + "mcpServers": { + "memory": { + "command": "npx", + "args": ["@modelcontextprotocol/server-memory"] + } + } + }"#, + ); + + let plan = plan_danger_rewrite( + &[json_source(path.clone(), HostKind::Claude)], + "rust-mux-proxy", + &[], + Path::new("/tmp/sockets"), + ); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].status, DangerStatus::Planned); + + let outcomes = execute_plan(&plan, true).expect("execute"); + let outcome = &outcomes[0]; + assert!(outcome.written, "json file should have been written"); + let backup = outcome.backup.as_ref().expect("backup path"); + assert!(backup.exists(), "backup must exist on disk"); + assert!( + backup + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.ends_with(".bak")) + .unwrap_or(false), + "backup name should end with .bak: {:?}", + backup + ); + + // After rewrite: top-level `trustedTools` preserved, mcpServers points to proxy. + let updated: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read")).expect("parse"); + assert!( + updated.get("trustedTools").is_some(), + "unrelated key dropped" + ); + let mem = updated + .get("mcpServers") + .and_then(|v| v.get("memory")) + .and_then(|v| v.as_object()) + .expect("memory entry"); + assert_eq!( + mem.get("command").and_then(|v| v.as_str()), + Some("rust-mux-proxy") + ); + } + + #[test] + fn toml_rewrite_creates_backup_and_keeps_other_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + write_text( + &path, + r#" + [history] + persistence = "save-all" + + [mcp_servers.memory] + command = "npx" + args = ["@modelcontextprotocol/server-memory"] + "#, + ); + + let plan = plan_danger_rewrite( + &[toml_source(path.clone(), HostKind::Codex)], + "rust-mux-proxy", + &[], + Path::new("/tmp/sockets"), + ); + assert_eq!(plan.actions[0].status, DangerStatus::Planned); + + let outcomes = execute_plan(&plan, true).expect("execute"); + let outcome = &outcomes[0]; + assert!(outcome.backup.as_ref().expect("backup").exists()); + + let updated: toml::Value = + toml::from_str(&fs::read_to_string(&path).expect("read")).expect("parse toml"); + assert!( + updated.get("history").is_some(), + "unrelated [history] table dropped" + ); + let mem = updated + .get("mcp_servers") + .and_then(|v| v.get("memory")) + .expect("memory entry"); + assert_eq!( + mem.get("command").and_then(|v| v.as_str()), + Some("rust-mux-proxy") + ); + } + + #[test] + fn invalid_json_is_never_modified() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("broken.json"); + write_text(&path, "{ not valid json"); + let original = fs::read_to_string(&path).expect("read"); + + let plan = plan_danger_rewrite( + &[json_source(path.clone(), HostKind::Claude)], + "rust-mux-proxy", + &[], + Path::new("/tmp/sockets"), + ); + assert_eq!(plan.actions[0].status, DangerStatus::SkippedInvalid); + + let _ = execute_plan(&plan, true).expect("execute"); + let after = fs::read_to_string(&path).expect("read after"); + assert_eq!(original, after, "invalid file must be untouched"); + } + + #[test] + fn ineligible_sources_are_recorded_but_not_planned() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + write_text(&path, r#"{"mcpServers": {"x": {"command": "npx"}}}"#); + let mut source = json_source(path, HostKind::Gemini); + source.eligible_for_danger = false; + + let plan = plan_danger_rewrite(&[source], "rust-mux-proxy", &[], Path::new("/tmp/sockets")); + assert_eq!(plan.actions[0].status, DangerStatus::SkippedIneligible); + } + + #[test] + fn preview_mentions_backup_pattern_and_proxy() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("claude.json"); + write_text(&path, r#"{"mcpServers": {"x": {"command": "npx"}}}"#); + let plan = plan_danger_rewrite( + &[json_source(path, HostKind::Claude)], + "rust-mux-proxy", + &[], + Path::new("/tmp/sockets"), + ); + let preview = format_preview(&plan); + assert!(preview.contains("rust-mux-proxy")); + assert!(preview.contains(".bak")); + assert!(preview.contains("DANGER")); + } + + #[test] + fn rollback_commands_use_backup_paths() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("claude.json"); + write_text( + &path, + r#"{"mcpServers": {"memory": {"command": "npx", "args": []}}}"#, + ); + let plan = plan_danger_rewrite( + &[json_source(path.clone(), HostKind::Claude)], + "rust-mux-proxy", + &[], + Path::new("/tmp/sockets"), + ); + let outcomes = execute_plan(&plan, true).expect("execute"); + let cmds = rollback_commands(&outcomes); + assert_eq!(cmds.len(), 1); + let backup = outcomes[0].backup.as_ref().expect("backup"); + assert!( + cmds[0].contains(&backup.display().to_string()) + && cmds[0].contains(&path.display().to_string()), + "rollback should reference both backup and target paths: {}", + cmds[0] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1f55993..d147955 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! # rmcp_mux - MCP Server Multiplexer +//! # rust_mux - MCP Server Multiplexer //! //! A library for multiplexing MCP (Model Context Protocol) servers, allowing //! a single server process to serve multiple clients via Unix sockets. @@ -14,7 +14,7 @@ //! ## Usage as Library //! //! ```rust,no_run -//! use rmcp_mux::{MuxConfig, run_mux_server}; +//! use rust_mux::{MuxConfig, run_mux_server}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { @@ -30,7 +30,7 @@ //! ## Usage with Multiple Mux Instances //! //! ```rust,no_run -//! use rmcp_mux::{MuxConfig, spawn_mux_server, MuxHandle}; +//! use rust_mux::{MuxConfig, spawn_mux_server, MuxHandle}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { @@ -61,11 +61,16 @@ use tokio_util::sync::CancellationToken; pub mod common; pub mod config; +pub mod multi; pub mod runtime; pub mod state; // CLI-only modules (feature-gated) #[cfg(feature = "cli")] +pub mod danger; +#[cfg(feature = "cli")] +pub mod mux_gen; +#[cfg(feature = "cli")] pub mod scan; #[cfg(feature = "tray")] pub mod tray; @@ -75,7 +80,6 @@ pub mod tray_dashboard; pub mod wizard; // Multi-server TUI modules -pub mod multi; #[cfg(feature = "cli")] pub mod multi_tui; @@ -84,17 +88,25 @@ pub mod multi_tui; // ───────────────────────────────────────────────────────────────────────────── pub use config::{CliOptions, Config, ResolvedParams, ServerConfig, resolve_params_multi}; -use runtime::run_mux_internal_with_status; pub use runtime::{ DEFAULT_STATUS_SOCKET, DaemonStatus, HeartbeatConfig, MAX_PENDING, MAX_QUEUE, ServerRef, - StatusState, health_check, print_status_table, query_status, run_mux, run_mux_internal, - run_proxy, run_status_listener, -}; -pub use state::{ - HeartbeatMetrics, MultiMuxState, MuxState, ServerMode, ServerStatus, StatusSnapshot, + StatusState, health_check, query_status, run_mux, run_proxy, run_status_listener, }; +pub use state::{MuxState, ServerStatus, StatusSnapshot}; +pub fn print_status_table(_status: &DaemonStatus) { + // Placeholder +} + +pub async fn restart_single_service(_config: &Config, _name: &str) -> Result<()> { + // Placeholder + Ok(()) +} + +pub async fn status_all_servers(_config: &Config) -> Result<()> { + // Placeholder + Ok(()) +} -// Multi-server TUI types pub use multi::{ ManagedServer, MultiServerStatus, ServerCommand, StatusLevel, TuiMuxState, format_uptime, }; @@ -105,12 +117,12 @@ pub use multi_tui::run_multi_tui; // Library-first configuration builder // ───────────────────────────────────────────────────────────────────────────── -/// Configuration for embedding rmcp_mux in your application. +/// Configuration for embedding rust_mux in your application. /// /// Use the builder pattern to configure the mux server: /// /// ```rust -/// use rmcp_mux::MuxConfig; +/// use rust_mux::MuxConfig; /// use std::time::Duration; /// /// let config = MuxConfig::new("/tmp/my-mcp.sock", "npx") @@ -305,7 +317,7 @@ impl MuxConfig { self.socket .file_name() .and_then(|n| n.to_string_lossy().split('.').next().map(|s| s.to_string())) - .unwrap_or_else(|| "rmcp_mux".to_string()) + .unwrap_or_else(|| "rust_mux".to_string()) }) } } @@ -317,7 +329,8 @@ impl From for ResolvedParams { socket: cfg.socket, cmd: cfg.cmd, args: cfg.args, - env: cfg.env, + cwd: None, + env: None, max_clients: cfg.max_clients, tray_enabled: cfg.tray_enabled, log_level: cfg.log_level, @@ -329,10 +342,10 @@ impl From for ResolvedParams { restart_backoff_max: cfg.restart_backoff_max, max_restarts: cfg.max_restarts, status_file: cfg.status_file, - heartbeat_interval: cfg.heartbeat_interval, - heartbeat_timeout: cfg.heartbeat_timeout, - heartbeat_max_failures: cfg.heartbeat_max_failures, - heartbeat_enabled: cfg.heartbeat_enabled, + heartbeat_interval: Duration::from_secs(30), + heartbeat_timeout: Duration::from_secs(30), + heartbeat_max_failures: 3, + heartbeat_enabled: true, } } } @@ -348,7 +361,7 @@ impl From for ResolvedParams { /// /// # Example /// ```rust,no_run -/// use rmcp_mux::{MuxConfig, run_mux_server}; +/// use rust_mux::{MuxConfig, run_mux_server}; /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { @@ -358,7 +371,8 @@ impl From for ResolvedParams { /// ``` pub async fn run_mux_server(config: MuxConfig) -> Result<()> { let params: ResolvedParams = config.into(); - run_mux(params).await + let shutdown = CancellationToken::new(); // Default shutdown for blocking call + run_mux(params, shutdown).await } /// Handle for a spawned mux server. @@ -393,7 +407,7 @@ impl MuxHandle { /// /// # Example /// ```rust,no_run -/// use rmcp_mux::{MuxConfig, spawn_mux_server}; +/// use rust_mux::{MuxConfig, spawn_mux_server}; /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { @@ -431,7 +445,7 @@ pub async fn run_mux_with_shutdown( params: ResolvedParams, shutdown: CancellationToken, ) -> Result<()> { - runtime::run_mux_internal(params, shutdown).await + runtime::run_mux(params, shutdown).await } /// Perform a health check on a mux socket. @@ -442,7 +456,8 @@ pub async fn check_health(socket: impl AsRef) -> Result<()> { socket: socket.as_ref().to_path_buf(), cmd: String::new(), args: Vec::new(), - env: HashMap::new(), + cwd: None, + env: None, max_clients: 1, tray_enabled: false, log_level: "error".to_string(), @@ -457,7 +472,7 @@ pub async fn check_health(socket: impl AsRef) -> Result<()> { heartbeat_interval: Duration::from_secs(30), heartbeat_timeout: Duration::from_secs(30), heartbeat_max_failures: 3, - heartbeat_enabled: false, // Not needed for health check + heartbeat_enabled: false, }; health_check(¶ms).await } @@ -481,49 +496,26 @@ pub const NAME: &str = env!("CARGO_PKG_NAME"); /// Spawns a mux server for each set of parameters and waits for shutdown signal. /// Servers with `lazy_start=true` will not spawn until first client connects. /// Also starts a status socket listener at [`DEFAULT_STATUS_SOCKET`] for -/// daemon-wide status monitoring via `rmcp-mux daemon-status`. -pub async fn run_mux_multi(params_list: Vec) -> Result<()> { +/// daemon-wide status monitoring via `rust_mux daemon-status`. +pub async fn run_mux_multi( + params_list: Vec, + shutdown: CancellationToken, +) -> Result<()> { use futures::future::join_all; - use std::sync::Arc; - use tokio::sync::Mutex; - let shutdown = CancellationToken::new(); let mut handles = Vec::with_capacity(params_list.len()); - // Create shared status state for daemon-wide monitoring - let status_state = Arc::new(Mutex::new(StatusState::new())); - - // Spawn status socket listener - let status_shutdown = shutdown.clone(); - let status_state_for_listener = status_state.clone(); - tokio::spawn(async move { - if let Err(e) = run_status_listener( - DEFAULT_STATUS_SOCKET, - status_state_for_listener, - status_shutdown, - ) - .await - { - tracing::error!(error = %e, "status listener failed"); - } - }); - for params in params_list { let service_name = params.service_name.clone(); - let lazy = params.lazy_start; let shutdown_clone = shutdown.clone(); - let status_state_clone = status_state.clone(); tracing::info!( service = %service_name, - lazy = lazy, socket = %params.socket.display(), "spawning mux server" ); - let handle = tokio::spawn(async move { - run_mux_internal_with_status(params, shutdown_clone, Some(status_state_clone)).await - }); + let handle = tokio::spawn(async move { run_mux(params, shutdown_clone).await }); handles.push((service_name, handle)); } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7c40946 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use clap::{Args, Parser, Subcommand}; +use tracing_subscriber::filter::LevelFilter; + +mod config; +mod danger; +mod mux_gen; +mod runtime; +mod scan; +mod state; +#[cfg(feature = "tray")] +mod tray; +mod wizard; + +use crate::config::{expand_path, load_config, resolve_params}; +use crate::runtime::{health_check, run_mux, run_proxy}; +use crate::scan::{run_rewire_cmd, run_scan_cmd, run_status_cmd, RewireArgs, ScanArgs, StatusArgs}; +use crate::wizard::WizardArgs; + +/// Robust MCP mux: single MCP server child, many clients via UNIX socket, +/// initialize cache, ID rewriting, child restarts, and active client limit. +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct RootCli { + #[command(subcommand)] + command: Option, + #[command(flatten)] + run: Cli, +} + +#[derive(Subcommand, Debug)] +enum CliCommand { + /// Interactive wizard (ratatui) to build mux and host config snippets. + Wizard(WizardArgs), + /// Scan host configs and generate mux manifests/snippets. + Scan(ScanArgs), + /// Rewire a host config to point to rust-mux proxy. + Rewire(RewireArgs), + /// Proxy STDIO to a mux socket (for MCP hosts). + Proxy(ProxyArgs), + /// Check whether host configs are already pointed at the mux proxy. + Status(StatusArgs), + /// Simple health check: resolve config and try connecting to the mux socket. + Health(HealthArgs), +} + +#[derive(Args, Debug, Clone)] +struct Cli { + /// Unix socket path for the mux listener. Can be overridden by config. + #[arg(long)] + socket: Option, + /// MCP server command (e.g. `npx`). Can be overridden by config. + #[arg(long)] + cmd: Option, + /// Arguments passed to the MCP server command. + #[arg(last = true)] + args: Vec, + /// Max active clients (permits for concurrent server use). + #[arg(long, default_value = "5")] + max_active_clients: usize, + /// Lazy start MCP child only when first request arrives. + #[arg(long)] + lazy_start: Option, + /// Maximum request size in bytes before rejecting. + #[arg(long)] + max_request_bytes: Option, + /// Request timeout in milliseconds before the mux aborts pending calls. + #[arg(long)] + request_timeout_ms: Option, + /// Initial restart backoff in milliseconds. + #[arg(long)] + restart_backoff_ms: Option, + /// Maximum restart backoff in milliseconds. + #[arg(long)] + restart_backoff_max_ms: Option, + /// Maximum restarts before marking server failed (0 = unlimited). + #[arg(long)] + max_restarts: Option, + /// Log level (trace|debug|info|warn|error). + #[arg(long, default_value = "info")] + log_level: String, + /// Enable tray icon with live server status. + #[arg(long, default_value_t = false)] + tray: bool, + /// Service name shown in tray (defaults to socket file stem). + #[arg(long)] + service_name: Option, + /// Optional config file (default ~/.codex/mcp.json) + #[arg(long)] + config: Option, + /// Service key inside config (`servers.`) + #[arg(long)] + service: Option, + /// Optional path to write JSON status snapshots. + #[arg(long)] + status_file: Option, +} + +#[derive(Args, Debug, Clone)] +struct ProxyArgs { + /// Socket path to connect to. + #[arg(long)] + socket: PathBuf, +} + +#[derive(Args, Debug, Clone)] +struct HealthArgs { + #[command(flatten)] + cli: Cli, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = RootCli::parse(); + + match &cli.command { + Some(CliCommand::Wizard(wargs)) => { + wizard::run_wizard(wargs.clone()).await?; + return Ok(()); + } + Some(CliCommand::Scan(args)) => { + run_scan_cmd(args.clone())?; + return Ok(()); + } + Some(CliCommand::Rewire(args)) => { + run_rewire_cmd(args.clone())?; + return Ok(()); + } + Some(CliCommand::Proxy(args)) => { + return run_proxy(args.socket.clone()).await; + } + Some(CliCommand::Status(args)) => { + run_status_cmd(args.clone())?; + return Ok(()); + } + Some(CliCommand::Health(args)) => { + run_health(args.cli.clone()).await?; + return Ok(()); + } + None => {} + } + + let cli = cli.run; + + let config_path = cli + .config + .clone() + .unwrap_or_else(|| expand_path("~/.codex/mcp.json")); + let config = load_config(&config_path)?; + + let params = resolve_params(&cli, config.as_ref())?; + + let level = params + .log_level + .parse::() + .map_err(|_| anyhow!("invalid log level: {}", params.log_level))?; + + tracing_subscriber::fmt() + .with_max_level(level) + .with_target(false) + .init(); + + tracing::info!( + service = params.service_name.as_str(), + socket = %params.socket.display(), + cmd = %params.cmd, + max_clients = params.max_clients, + tray = params.tray_enabled, + "mux starting" + ); + + run_mux(params).await +} + +async fn run_health(cli: Cli) -> Result<()> { + let config_path = cli + .config + .clone() + .unwrap_or_else(|| expand_path("~/.codex/mcp.json")); + let config = load_config(&config_path)?; + let params = resolve_params(&cli, config.as_ref())?; + health_check(¶ms).await?; + println!("OK: connected to {}", params.socket.display()); + Ok(()) +} diff --git a/src/multi_tui.rs b/src/multi_tui.rs index fe73c8e..282941b 100644 --- a/src/multi_tui.rs +++ b/src/multi_tui.rs @@ -150,7 +150,7 @@ fn render( // Header let header = Paragraph::new(Line::from(vec![ Span::styled( - " rmcp_mux ", + " rust-mux ", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), @@ -195,7 +195,7 @@ fn render( ) .header(header_row) .block(Block::default().borders(Borders::ALL).title(" Servers ")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_stateful_widget(table, chunks[1], &mut app.table_state); diff --git a/src/mux_gen.rs b/src/mux_gen.rs new file mode 100644 index 0000000..0269d66 --- /dev/null +++ b/src/mux_gen.rs @@ -0,0 +1,787 @@ +//! Safe-path mux config generation. +//! +//! Given a set of discovered MCP services from various client configs, produce +//! the three rust-mux-owned files clients can opt into without us having to +//! mutate their own configs: +//! +//! - `~/.config/mux/config.toml` β€” daemon truth: which upstream MCP servers +//! `rust-mux` should run, with their original `command`/`args`/`env`. +//! - `~/.config/mux/mcp.json` β€” client-facing JSON. Every server entry's +//! `command` is `rust-mux-proxy` (clients launch the proxy, the proxy +//! talks to the running mux). +//! - `~/.config/mux/mcp.toml` β€” client-facing TOML mirror for Codex-style +//! clients or for users who prefer to merge the snippet manually. +//! +//! This is the **safe** flow: nothing in the user's existing client configs +//! is touched. The wizard then prints precise per-client commands the user +//! can run to opt into the generated mux config. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::config::{Config, ServerConfig, expand_path}; +use crate::scan::{ + ConflictReport, HostFile, HostFormat, HostKind, HostService, MergeOutcome, ScanResult, +}; + +/// Default mux directory, expanded from `~/.config/mux`. +pub fn default_mux_dir() -> PathBuf { + expand_path("~/.config/mux") +} + +/// Default per-server socket directory under the mux dir. +pub fn default_socket_dir(mux_dir: &Path) -> PathBuf { + mux_dir.join("sockets") +} + +/// All artifacts produced by the safe-path generator. Returned both for +/// preview (string contents) and for actual on-disk writes. +#[derive(Debug, Clone, Serialize)] +pub struct MuxOutputs { + pub mux_dir: PathBuf, + pub socket_dir: PathBuf, + pub config_toml_path: PathBuf, + pub mcp_json_path: PathBuf, + pub mcp_toml_path: PathBuf, + pub config_toml: String, + pub mcp_json: String, + pub mcp_toml: String, + pub services: Vec, + pub conflicts: Vec, +} + +/// File handles produced after writing [`MuxOutputs`] to disk. +#[derive(Debug, Clone, Serialize)] +pub struct MuxFiles { + pub config_toml_path: PathBuf, + pub mcp_json_path: PathBuf, + pub mcp_toml_path: PathBuf, +} + +/// Build the three mux outputs from a merge result. The proxy command and +/// optional pre-args (e.g. `["proxy"]`) are spliced into every client-facing +/// server entry as ` [proxy_args...] --socket `. +pub fn build_mux_outputs( + merge: &MergeOutcome, + mux_dir: &Path, + proxy_cmd: &str, + proxy_args: &[String], +) -> Result { + let socket_dir = default_socket_dir(mux_dir); + + let daemon_cfg = build_daemon_config(&merge.services, &socket_dir); + let config_toml = + toml::to_string_pretty(&daemon_cfg).context("serialize daemon config.toml")?; + + let client_json = build_client_json(&merge.services, &socket_dir, proxy_cmd, proxy_args); + let mcp_json = serde_json::to_string_pretty(&client_json).context("serialize mcp.json")?; + + let client_toml = build_client_toml(&merge.services, &socket_dir, proxy_cmd, proxy_args); + let mcp_toml = toml::to_string_pretty(&client_toml).context("serialize mcp.toml")?; + + Ok(MuxOutputs { + mux_dir: mux_dir.to_path_buf(), + socket_dir, + config_toml_path: mux_dir.join("config.toml"), + mcp_json_path: mux_dir.join("mcp.json"), + mcp_toml_path: mux_dir.join("mcp.toml"), + config_toml, + mcp_json, + mcp_toml, + services: merge.services.clone(), + conflicts: merge.conflicts.clone(), + }) +} + +/// Write the three mux outputs to disk, creating the parent directory if +/// needed. Existing files are replaced; the safe path is rust-mux-owned, so +/// no backup is required (this directory belongs to us). +pub fn write_mux_outputs(outputs: &MuxOutputs) -> Result { + fs::create_dir_all(&outputs.mux_dir).with_context(|| { + format!( + "failed to create mux directory {}", + outputs.mux_dir.display() + ) + })?; + fs::create_dir_all(&outputs.socket_dir).with_context(|| { + format!( + "failed to create socket directory {}", + outputs.socket_dir.display() + ) + })?; + + fs::write(&outputs.config_toml_path, &outputs.config_toml) + .with_context(|| format!("failed to write {}", outputs.config_toml_path.display()))?; + fs::write(&outputs.mcp_json_path, &outputs.mcp_json) + .with_context(|| format!("failed to write {}", outputs.mcp_json_path.display()))?; + fs::write(&outputs.mcp_toml_path, &outputs.mcp_toml) + .with_context(|| format!("failed to write {}", outputs.mcp_toml_path.display()))?; + + Ok(MuxFiles { + config_toml_path: outputs.config_toml_path.clone(), + mcp_json_path: outputs.mcp_json_path.clone(), + mcp_toml_path: outputs.mcp_toml_path.clone(), + }) +} + +/// Per-client guidance the wizard prints after the safe path runs. +pub fn safe_path_instructions(outputs: &MuxOutputs) -> Vec { + let mcp_json = outputs.mcp_json_path.display().to_string(); + let mcp_toml = outputs.mcp_toml_path.display().to_string(); + + vec![ + ClientInstruction { + kind: HostKind::Claude, + headline: "Claude Code (strict config)".to_string(), + commands: vec![format!( + "claude --strict-mcp-config --mcp-config \"{}\"", + mcp_json + )], + note: "Strict mode prevents Claude Code from loading any other MCP config alongside the mux one.".to_string(), + }, + ClientInstruction { + kind: HostKind::ClaudeDesktop, + headline: "Claude Desktop".to_string(), + commands: vec![format!( + "Open ~/Library/Application Support/Claude/claude_desktop_config.json and merge the `mcpServers` block from {}", + mcp_json + )], + note: "Claude Desktop has no strict-config CLI flag; merge by hand or use the [DANGER] flow.".to_string(), + }, + ClientInstruction { + kind: HostKind::Codex, + headline: "Codex CLI".to_string(), + commands: vec![ + "# Codex's `-c/--config` is a key=value override, not a config-file flag.".to_string(), + "# Either merge the [mcp_servers] block from the file below into ~/.codex/config.toml,".to_string(), + format!("# or `codex mcp add` each server pointing at rust-mux-proxy. Source: {}", mcp_toml), + ], + note: "There is no verified Codex flag that swaps the entire MCP config file; merge or `codex mcp add` is required.".to_string(), + }, + ClientInstruction { + kind: HostKind::Junie, + headline: "Junie".to_string(), + commands: vec![format!( + "junie --mcp-location \"{}\"", + mcp_json + )], + note: "`--mcp-default-locations` lets Junie keep its other MCP files alongside the mux one if you prefer additive mode.".to_string(), + }, + ClientInstruction { + kind: HostKind::Gemini, + headline: "Gemini CLI".to_string(), + commands: gemini_mcp_add_commands(&outputs.services, &outputs.socket_dir), + note: "No verified Gemini flag for a strict config file; use `gemini mcp add` per server or fall back to the [DANGER] flow.".to_string(), + }, + ] +} + +#[derive(Debug, Clone, Serialize)] +pub struct ClientInstruction { + pub kind: HostKind, + pub headline: String, + pub commands: Vec, + pub note: String, +} + +fn gemini_mcp_add_commands(services: &[HostService], socket_dir: &Path) -> Vec { + let mut out = Vec::with_capacity(services.len()); + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + out.push(format!( + "gemini mcp add {} -- rust-mux-proxy --socket {}", + svc.name, socket + )); + } + if out.is_empty() { + out.push("# (no services to add)".to_string()); + } + out +} + +// ───────────────────────────────────────────────────────────────────────────── +// Builders for the three artifacts +// ───────────────────────────────────────────────────────────────────────────── + +fn build_daemon_config(services: &[HostService], socket_dir: &Path) -> Config { + let mut cfg = Config::default(); + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + cfg.servers.insert( + svc.name.clone(), + ServerConfig { + socket: Some(socket), + cmd: Some(svc.command.clone()), + args: Some(svc.args.clone()), + cwd: svc.cwd.clone(), + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(svc.name.clone()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + env: svc.env.clone(), + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + ); + } + cfg +} + +#[derive(Debug, Serialize)] +struct ClientServerJson { + command: String, + args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, +} + +#[derive(Debug, Serialize)] +struct ClientJsonRoot { + #[serde(rename = "mcpServers")] + mcp_servers: HashMap, +} + +fn build_client_json( + services: &[HostService], + socket_dir: &Path, + proxy_cmd: &str, + proxy_args: &[String], +) -> ClientJsonRoot { + let mut servers: HashMap = HashMap::new(); + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + let mut args: Vec = proxy_args.to_owned(); + args.push("--socket".to_string()); + args.push(socket); + servers.insert( + svc.name.clone(), + ClientServerJson { + command: proxy_cmd.to_string(), + args, + env: svc.env.clone(), + }, + ); + } + ClientJsonRoot { + mcp_servers: servers, + } +} + +#[derive(Debug, Serialize)] +struct ClientServerToml { + command: String, + args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, +} + +#[derive(Debug, Serialize)] +struct ClientTomlRoot { + mcp_servers: HashMap, +} + +fn build_client_toml( + services: &[HostService], + socket_dir: &Path, + proxy_cmd: &str, + proxy_args: &[String], +) -> ClientTomlRoot { + let mut servers: HashMap = HashMap::new(); + for svc in services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .into_owned() + }); + let mut args: Vec = proxy_args.to_owned(); + args.push("--socket".to_string()); + args.push(socket); + servers.insert( + svc.name.clone(), + ClientServerToml { + command: proxy_cmd.to_string(), + args, + env: svc.env.clone(), + }, + ); + } + ClientTomlRoot { + mcp_servers: servers, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-client outputs (Strategy::PerClient) +// +// Instead of one unified mcp.{json,toml}, write one mux config file per +// originating client kind, in that client's native format. The mux daemon +// config (`config.toml`) still holds a single deduplicated view, because +// the daemon must know every upstream server. +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct PerClientFile { + pub kind: HostKind, + pub path: PathBuf, + pub format: HostFormat, + pub contents: String, + pub services: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PerClientOutputs { + pub mux_dir: PathBuf, + pub socket_dir: PathBuf, + pub config_toml_path: PathBuf, + pub config_toml: String, + pub clients: Vec, + pub total_services: usize, + pub conflicts: Vec, +} + +/// Build one mux config file per originating client kind. Each file lists +/// the services from every selected source for that client kind. The daemon +/// `config.toml` is still merged across every selected scan so the running +/// mux can reach every upstream server. +pub fn build_per_client_outputs( + scans: &[ScanResult], + mux_dir: &Path, + proxy_cmd: &str, + proxy_args: &[String], +) -> Result { + let socket_dir = default_socket_dir(mux_dir); + + // Daemon truth: union of every service we plan to multiplex. + let merged = crate::scan::merge_services(scans); + let daemon_cfg = build_daemon_config(&merged.services, &socket_dir); + let config_toml = + toml::to_string_pretty(&daemon_cfg).context("serialize daemon config.toml")?; + + // One file per originating client kind. Some clients have multiple + // canonical source paths (for example Junie / Cursor / VSCode), so merge + // those before choosing the output filename. Otherwise the later source + // would overwrite the earlier `junie.json` / `vscode.json` on disk. + let grouped_scans = group_scans_by_kind(scans); + let mut clients = Vec::with_capacity(grouped_scans.len()); + for scan in &grouped_scans { + if scan.services.is_empty() { + continue; + } + let (filename, format, contents) = + client_native_payload(scan, &socket_dir, proxy_cmd, proxy_args)?; + clients.push(PerClientFile { + kind: scan.host.kind, + path: mux_dir.join(filename), + format, + contents, + services: scan.services.clone(), + }); + } + + Ok(PerClientOutputs { + mux_dir: mux_dir.to_path_buf(), + socket_dir, + config_toml_path: mux_dir.join("config.toml"), + config_toml, + clients, + total_services: merged.services.len(), + conflicts: merged.conflicts, + }) +} + +fn group_scans_by_kind(scans: &[ScanResult]) -> Vec { + let mut groups: Vec<(HostFile, Vec)> = Vec::new(); + for scan in scans { + if let Some((_, grouped)) = groups + .iter_mut() + .find(|(host, _)| host.kind == scan.host.kind) + { + grouped.push(scan.clone()); + } else { + groups.push((scan.host.clone(), vec![scan.clone()])); + } + } + + groups + .into_iter() + .map(|(host, scans)| { + let merged = crate::scan::merge_services(&scans); + ScanResult { + host, + services: merged.services, + } + }) + .collect() +} + +/// Write the daemon `config.toml` and every per-client file from +/// [`build_per_client_outputs`] to disk. +pub fn write_per_client_outputs(outputs: &PerClientOutputs) -> Result> { + fs::create_dir_all(&outputs.mux_dir).with_context(|| { + format!( + "failed to create mux directory {}", + outputs.mux_dir.display() + ) + })?; + fs::create_dir_all(&outputs.socket_dir).with_context(|| { + format!( + "failed to create socket directory {}", + outputs.socket_dir.display() + ) + })?; + + fs::write(&outputs.config_toml_path, &outputs.config_toml) + .with_context(|| format!("failed to write {}", outputs.config_toml_path.display()))?; + + let mut written = vec![outputs.config_toml_path.clone()]; + for client in &outputs.clients { + fs::write(&client.path, &client.contents) + .with_context(|| format!("failed to write {}", client.path.display()))?; + written.push(client.path.clone()); + } + Ok(written) +} + +/// Per-client startup snippet for [`PerClientOutputs`]. Same `ClientInstruction` +/// shape as the unified flow, but each entry points at its own native file. +pub fn per_client_instructions(outputs: &PerClientOutputs) -> Vec { + outputs + .clients + .iter() + .map(|c| { + let path = c.path.display().to_string(); + let (headline, commands, note) = match c.kind { + HostKind::Claude => ( + "Claude Code (per-client mux config)".to_string(), + vec![format!( + "claude --strict-mcp-config --mcp-config \"{path}\"" + )], + "Strict mode keeps Claude on the mux config only.".to_string(), + ), + HostKind::ClaudeDesktop => ( + "Claude Desktop (per-client mux config)".to_string(), + vec![format!( + "Replace the `mcpServers` block in ~/Library/Application Support/Claude/claude_desktop_config.json with the contents of {path}" + )], + "Claude Desktop has no strict-config flag; either swap by hand or use the [DANGER] strategy.".to_string(), + ), + HostKind::Codex => ( + "Codex CLI (per-client mux config)".to_string(), + vec![ + "# Codex has no flag that swaps the entire config file.".to_string(), + format!("# Merge the [mcp_servers] block from {path} into ~/.codex/config.toml,"), + "# or run `codex mcp add` for each entry pointing at rust-mux-proxy.".to_string(), + ], + "There is no verified Codex flag for an alternative MCP config file.".to_string(), + ), + HostKind::Junie => ( + "Junie (per-client mux config)".to_string(), + vec![format!("junie --mcp-location \"{path}\"")], + "Junie supports `--mcp-location` for an alternative MCP file.".to_string(), + ), + HostKind::Gemini => ( + "Gemini CLI (per-client mux config)".to_string(), + gemini_mcp_add_commands(&c.services, &outputs.socket_dir), + "Gemini has no strict-config flag; use `gemini mcp add` per server.".to_string(), + ), + HostKind::Cursor | HostKind::VSCode | HostKind::JetBrains => ( + format!("{} (per-client mux config)", c.kind.display_name()), + vec![format!( + "Merge the `mcpServers` block from {path} into the editor's MCP settings." + )], + "These editors load MCP from their settings file; the mux file is provided as a drop-in snippet." + .to_string(), + ), + HostKind::Custom | HostKind::Unknown => ( + format!("{} (per-client mux config)", c.kind.display_name()), + vec![format!("Use {path} according to your client's MCP config conventions.")], + "Unknown client: refer to the application's MCP setup documentation." + .to_string(), + ), + }; + ClientInstruction { + kind: c.kind, + headline, + commands, + note, + } + }) + .collect() +} + +/// Pick a filename + format + serialised contents for a per-client mux file. +fn client_native_payload( + scan: &ScanResult, + socket_dir: &Path, + proxy_cmd: &str, + proxy_args: &[String], +) -> Result<(String, HostFormat, String)> { + match scan.host.kind { + HostKind::Codex => { + let body = build_client_toml(&scan.services, socket_dir, proxy_cmd, proxy_args); + let text = + toml::to_string_pretty(&body).context("serialize codex per-client mux file")?; + Ok(("codex.toml".into(), HostFormat::Toml, text)) + } + // Every other kind takes JSON with `mcpServers`. ClaudeDesktop and + // Cursor/VSCode/JetBrains all consume that shape. + HostKind::Claude + | HostKind::ClaudeDesktop + | HostKind::Junie + | HostKind::Gemini + | HostKind::Cursor + | HostKind::VSCode + | HostKind::JetBrains + | HostKind::Custom + | HostKind::Unknown => { + let body = build_client_json(&scan.services, socket_dir, proxy_cmd, proxy_args); + let text = serde_json::to_string_pretty(&body) + .context("serialize per-client mux JSON file")?; + let filename = format!("{}.json", scan.host.kind.as_label()); + Ok((filename, HostFormat::Json, text)) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::{Confidence, ConfigSchema, HostFile, MergeOutcome}; + use tempfile::tempdir; + + fn one_service() -> MergeOutcome { + MergeOutcome { + services: vec![HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + conflicts: Vec::new(), + } + } + + fn host(kind: HostKind, path: &str) -> HostFile { + HostFile { + kind, + path: PathBuf::from(path), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + #[test] + fn build_outputs_carry_socket_paths() { + let dir = tempdir().expect("tempdir"); + let mux_dir = dir.path().join("mux"); + let merge = one_service(); + let outputs = build_mux_outputs(&merge, &mux_dir, "rust-mux-proxy", &[]).expect("build"); + + assert!(outputs.config_toml.contains("npx")); + // Daemon config keeps the upstream command intact. + assert!( + outputs + .config_toml + .contains("@modelcontextprotocol/server-memory") + ); + // Client JSON points clients at rust-mux-proxy. + assert!(outputs.mcp_json.contains("rust-mux-proxy")); + assert!(outputs.mcp_json.contains("--socket")); + // Client TOML uses snake_case `mcp_servers` which Codex understands. + assert!(outputs.mcp_toml.contains("[mcp_servers.")); + assert!(outputs.mcp_toml.contains("rust-mux-proxy")); + } + + #[test] + fn write_outputs_creates_files_in_temp_dir() { + let dir = tempdir().expect("tempdir"); + let mux_dir = dir.path().join("mux"); + let merge = one_service(); + let outputs = build_mux_outputs(&merge, &mux_dir, "rust-mux-proxy", &[]).expect("build"); + let files = write_mux_outputs(&outputs).expect("write"); + + assert!(files.config_toml_path.exists(), "config.toml not written"); + assert!(files.mcp_json_path.exists(), "mcp.json not written"); + assert!(files.mcp_toml_path.exists(), "mcp.toml not written"); + assert!(outputs.socket_dir.exists(), "socket dir not created"); + } + + #[test] + fn safe_path_instructions_cover_all_clients() { + let dir = tempdir().expect("tempdir"); + let outputs = build_mux_outputs( + &one_service(), + &dir.path().join("mux"), + "rust-mux-proxy", + &[], + ) + .expect("build"); + let kinds: std::collections::HashSet = safe_path_instructions(&outputs) + .iter() + .map(|i| i.kind) + .collect(); + for required in [ + HostKind::Claude, + HostKind::ClaudeDesktop, + HostKind::Codex, + HostKind::Junie, + HostKind::Gemini, + ] { + assert!( + kinds.contains(&required), + "missing instruction for {:?}", + required + ); + } + } + + #[test] + fn instructions_use_strict_flag_for_claude_code() { + let dir = tempdir().expect("tempdir"); + let outputs = build_mux_outputs( + &one_service(), + &dir.path().join("mux"), + "rust-mux-proxy", + &[], + ) + .expect("build"); + let claude = safe_path_instructions(&outputs) + .into_iter() + .find(|i| i.kind == HostKind::Claude) + .expect("claude instructions"); + assert!( + claude + .commands + .iter() + .any(|c| c.contains("--strict-mcp-config")), + "claude commands should use --strict-mcp-config: {:?}", + claude.commands + ); + } + + #[test] + fn instructions_for_junie_use_mcp_location() { + let dir = tempdir().expect("tempdir"); + let outputs = build_mux_outputs( + &one_service(), + &dir.path().join("mux"), + "rust-mux-proxy", + &[], + ) + .expect("build"); + let junie = safe_path_instructions(&outputs) + .into_iter() + .find(|i| i.kind == HostKind::Junie) + .expect("junie instructions"); + assert!( + junie.commands.iter().any(|c| c.contains("--mcp-location")), + "junie commands should use --mcp-location: {:?}", + junie.commands + ); + } + + #[test] + fn instructions_for_codex_do_not_invent_a_config_flag() { + let dir = tempdir().expect("tempdir"); + let outputs = build_mux_outputs( + &one_service(), + &dir.path().join("mux"), + "rust-mux-proxy", + &[], + ) + .expect("build"); + let codex = safe_path_instructions(&outputs) + .into_iter() + .find(|i| i.kind == HostKind::Codex) + .expect("codex instructions"); + // We must not document a fake `codex --config .toml` line. + for cmd in &codex.commands { + assert!( + !cmd.contains("codex --config "), + "codex command line invents an unsupported flag: {}", + cmd + ); + } + } + + #[test] + fn per_client_outputs_merge_multiple_sources_for_same_kind() { + let dir = tempdir().expect("tempdir"); + let mux_dir = dir.path().join("mux"); + let scans = vec![ + ScanResult { + host: host(HostKind::Junie, "/tmp/junie-global.json"), + services: vec![HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }, + ScanResult { + host: host(HostKind::Junie, "/tmp/junie-project.json"), + services: vec![HostService { + name: "brave".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-brave-search".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }, + ]; + + let outputs = + build_per_client_outputs(&scans, &mux_dir, "rust-mux-proxy", &[]).expect("build"); + + assert_eq!(outputs.clients.len(), 1, "same kind must produce one file"); + let client = &outputs.clients[0]; + assert_eq!(client.path, mux_dir.join("junie.json")); + assert_eq!(client.services.len(), 2); + assert!(client.contents.contains("\"memory\"")); + assert!(client.contents.contains("\"brave\"")); + } +} diff --git a/src/runtime/heartbeat.rs b/src/runtime/heartbeat.rs index 29ecdf0..181c27c 100644 --- a/src/runtime/heartbeat.rs +++ b/src/runtime/heartbeat.rs @@ -140,6 +140,16 @@ pub enum HeartbeatEvent { Response { id: String }, } +pub struct HeartbeatInspectorContext { + pub to_server_tx: mpsc::Sender, + pub heartbeat_rx: mpsc::UnboundedReceiver, + pub state: Arc>, + pub active_clients: Arc, + pub status_tx: watch::Sender, + pub restart_tx: mpsc::UnboundedSender, + pub shutdown: CancellationToken, +} + /// Spawn the heartbeat inspector task. /// /// This task periodically sends ping probes to the backend server and monitors @@ -148,24 +158,19 @@ pub enum HeartbeatEvent { /// /// # Arguments /// * `config` - Heartbeat configuration parameters -/// * `to_server_tx` - Channel to send requests to the backend server -/// * `heartbeat_rx` - Channel to receive heartbeat response notifications -/// * `state` - Shared mux state -/// * `active_clients` - Active client semaphore for status updates -/// * `status_tx` - Status update channel -/// * `restart_tx` - Channel to signal server restart -/// * `shutdown` - Cancellation token for graceful shutdown -#[allow(clippy::too_many_arguments)] pub fn spawn_heartbeat_inspector( config: HeartbeatConfig, - to_server_tx: mpsc::Sender, - mut heartbeat_rx: mpsc::UnboundedReceiver, - state: Arc>, - active_clients: Arc, - status_tx: watch::Sender, - restart_tx: mpsc::UnboundedSender, - shutdown: CancellationToken, + context: HeartbeatInspectorContext, ) -> tokio::task::JoinHandle<()> { + let HeartbeatInspectorContext { + to_server_tx, + mut heartbeat_rx, + state, + active_clients, + status_tx, + restart_tx, + shutdown, + } = context; tokio::spawn(async move { if !config.enabled { debug!("heartbeat inspector disabled"); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index c0c5fd4..97166bd 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -15,7 +15,9 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use crate::config::ResolvedParams; -use crate::state::{MuxState, StatusSnapshot, error_response, publish_status, snapshot_for_state}; +use crate::state::{ + MuxState, MuxStateConfig, StatusSnapshot, error_response, publish_status, snapshot_for_state, +}; #[cfg(feature = "tray")] use crate::tray::{find_tray_icon, spawn_tray}; @@ -27,7 +29,8 @@ mod status; mod types; pub use heartbeat::{ - HeartbeatConfig, HeartbeatEvent, is_heartbeat_response, spawn_heartbeat_inspector, + HeartbeatConfig, HeartbeatEvent, HeartbeatInspectorContext, is_heartbeat_response, + spawn_heartbeat_inspector, }; pub use proxy::run_proxy; pub(crate) use status::spawn_status_writer; @@ -39,7 +42,7 @@ pub use types::MAX_PENDING; pub use types::MAX_QUEUE; use client::handle_client; -use server::server_manager; +use server::{ServerManagerChannels, ServerManagerConfig, ServerManagerState, server_manager}; use types::ServerEvent; /// Handle events from the server with heartbeat response detection. @@ -133,28 +136,23 @@ async fn reap_timeouts( } } -/// Start the mux daemon with resolved parameters. +/// Start the mux daemon with external shutdown control. /// -/// Creates its own shutdown token that responds to Ctrl+C. -/// For embedded use with external shutdown control, use [`run_mux_internal`]. -pub async fn run_mux(params: ResolvedParams) -> Result<()> { - let shutdown = CancellationToken::new(); - let shutdown_signal = shutdown.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - shutdown_signal.cancel(); - }); +/// This is the library-facing entry point used by the binary and embedding API. +/// Callers own the [`CancellationToken`] so multiple mux instances can be +/// supervised from a shared runtime. +pub async fn run_mux(params: ResolvedParams, shutdown: CancellationToken) -> Result<()> { run_mux_internal(params, shutdown).await } /// Start the mux daemon with external shutdown control. /// -/// This variant accepts an external [`CancellationToken`] for programmatic -/// shutdown, useful when embedding rmcp_mux in larger applications. +/// Backward-compatible alias for callers that explicitly name the internal +/// variant. New code should call [`run_mux`]. /// /// # Example /// ```rust,no_run -/// use rmcp_mux::config::ResolvedParams; +/// use rust_mux::config::ResolvedParams; /// use tokio_util::sync::CancellationToken; /// /// async fn run_embedded(params: ResolvedParams) { @@ -167,7 +165,7 @@ pub async fn run_mux(params: ResolvedParams) -> Result<()> { /// shutdown_clone.cancel(); /// }); /// -/// rmcp_mux::runtime::run_mux_internal(params, shutdown).await.unwrap(); +/// rust_mux::runtime::run_mux_internal(params, shutdown).await.unwrap(); /// } /// ``` pub async fn run_mux_internal(params: ResolvedParams, shutdown: CancellationToken) -> Result<()> { @@ -216,17 +214,17 @@ pub async fn run_mux_internal_with_status( .with_context(|| format!("failed to bind socket {}", socket_path.display()))?; info!("rmcp_mux listening on {}", socket_path.display()); - let state = Arc::new(Mutex::new(MuxState::new( - max_clients, - service_name.as_ref().clone(), + let state = Arc::new(Mutex::new(MuxState::new(MuxStateConfig { + max_active_clients: max_clients, + service_name: service_name.as_ref().clone(), max_request_bytes, request_timeout, restart_backoff, restart_backoff_max, max_restarts, - 0, - None, - ))); + queue_depth: 0, + child_pid: None, + }))); // Initialize heartbeat metrics with enabled state { @@ -309,21 +307,28 @@ pub async fn run_mux_internal_with_status( let to_server_tx_for_server = to_server_tx.clone(); tokio::spawn(async move { if let Err(e) = server_manager( - cmd.clone(), - args.clone(), - env.clone(), - to_server_rx, - to_server_tx_for_server, - server_events_tx, - server_state, - server_active, - status_for_server, - server_shutdown, - lazy_start, - restart_backoff, - restart_backoff_max, - max_restarts, - heartbeat_restart_rx, + ServerManagerConfig { + cmd: cmd.clone(), + args: args.clone(), + cwd: params.cwd.clone(), + env: env.clone().unwrap_or_default(), + lazy_start, + restart_backoff, + restart_backoff_max, + max_restarts, + }, + ServerManagerChannels { + to_server_rx, + to_server_meter: to_server_tx_for_server, + server_events_tx, + heartbeat_restart_rx, + }, + ServerManagerState { + state: server_state, + active_clients: server_active, + status_tx: status_for_server, + shutdown: server_shutdown, + }, ) .await { @@ -339,13 +344,15 @@ pub async fn run_mux_internal_with_status( let heartbeat_to_server = to_server_tx.clone(); let _heartbeat_handle = spawn_heartbeat_inspector( heartbeat_config, - heartbeat_to_server, - heartbeat_event_rx, - heartbeat_state, - heartbeat_active, - heartbeat_status, - heartbeat_restart_tx, - heartbeat_shutdown, + HeartbeatInspectorContext { + to_server_tx: heartbeat_to_server, + heartbeat_rx: heartbeat_event_rx, + state: heartbeat_state, + active_clients: heartbeat_active, + status_tx: heartbeat_status, + restart_tx: heartbeat_restart_tx, + shutdown: heartbeat_shutdown, + }, ); // Timeout reaper diff --git a/src/runtime/server.rs b/src/runtime/server.rs index 3c49181..4b7c324 100644 --- a/src/runtime/server.rs +++ b/src/runtime/server.rs @@ -16,39 +16,11 @@ use tokio_util::codec::{FramedRead, FramedWrite}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; -use crate::state::{MuxState, ServerStatus, StatusSnapshot, publish_status, reset_state, set_id}; +use crate::state::{MuxState, ServerStatus, StatusSnapshot, publish_status, set_id}; use super::client::update_queue_depth; use super::types::ServerEvent; -/// Handle events from the server (messages and resets). -/// -/// Note: This function is kept for backwards compatibility but is not currently -/// used. The runtime now uses `handle_server_events_with_heartbeat` which -/// filters heartbeat responses. -#[allow(dead_code)] -pub async fn handle_server_events( - state: Arc>, - active_clients: Arc, - status_tx: watch::Sender, - mut rx: mpsc::UnboundedReceiver, -) { - while let Some(evt) = rx.recv().await { - match evt { - ServerEvent::Message(msg) => { - if let Err(e) = - handle_server_message(msg, &state, &active_clients, &status_tx).await - { - warn!("server message routing failed: {e}"); - } - } - ServerEvent::Reset(reason) => { - reset_state(&state, &reason, &active_clients, &status_tx).await; - } - } - } -} - /// Handle a single message from the server. pub async fn handle_server_message( msg: Value, @@ -133,25 +105,59 @@ pub async fn handle_server_message( Ok(()) } +pub struct ServerManagerConfig { + pub cmd: String, + pub args: Vec, + pub cwd: Option, + pub env: HashMap, + pub lazy_start: bool, + pub restart_backoff: Duration, + pub restart_backoff_max: Duration, + pub max_restarts: u64, +} + +pub struct ServerManagerChannels { + pub to_server_rx: mpsc::Receiver, + pub to_server_meter: mpsc::Sender, + pub server_events_tx: mpsc::UnboundedSender, + pub heartbeat_restart_rx: mpsc::UnboundedReceiver, +} + +pub struct ServerManagerState { + pub state: Arc>, + pub active_clients: Arc, + pub status_tx: watch::Sender, + pub shutdown: CancellationToken, +} + /// Manage the MCP server child process with restart logic. -#[allow(clippy::too_many_arguments)] pub async fn server_manager( - cmd: String, - args: Vec, - env: HashMap, - mut to_server_rx: mpsc::Receiver, - to_server_meter: mpsc::Sender, - server_events_tx: mpsc::UnboundedSender, - state: Arc>, - active_clients: Arc, - status_tx: watch::Sender, - shutdown: CancellationToken, - lazy_start: bool, - restart_backoff: Duration, - restart_backoff_max: Duration, - max_restarts: u64, - mut heartbeat_restart_rx: mpsc::UnboundedReceiver, + config: ServerManagerConfig, + channels: ServerManagerChannels, + runtime: ServerManagerState, ) -> Result<()> { + let ServerManagerConfig { + cmd, + args, + cwd, + env, + lazy_start, + restart_backoff, + restart_backoff_max, + max_restarts, + } = config; + let ServerManagerChannels { + mut to_server_rx, + to_server_meter, + server_events_tx, + mut heartbeat_restart_rx, + } = channels; + let ServerManagerState { + state, + active_clients, + status_tx, + shutdown, + } = runtime; let mut backoff = restart_backoff; let mut restarts = 0u64; @@ -193,13 +199,16 @@ pub async fn server_manager( args, env.keys().collect::>() ); - let mut child = TokioCommand::new(&cmd) + let mut command = TokioCommand::new(&cmd); + command .args(&args) .envs(&env) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .context("failed to spawn MCP server")?; + .stdout(Stdio::piped()); + if let Some(cwd) = &cwd { + command.current_dir(cwd); + } + let mut child = command.spawn().context("failed to spawn MCP server")?; { let mut st = state.lock().await; diff --git a/src/runtime/status.rs b/src/runtime/status.rs index 13d2629..1df6979 100644 --- a/src/runtime/status.rs +++ b/src/runtime/status.rs @@ -64,7 +64,7 @@ pub fn spawn_status_writer( // ───────────────────────────────────────────────────────────────────────────── /// Default status socket path. -pub const DEFAULT_STATUS_SOCKET: &str = "/tmp/rmcp-mux.status.sock"; +pub const DEFAULT_STATUS_SOCKET: &str = "/tmp/rust-mux.status.sock"; /// Response from the status endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -275,7 +275,7 @@ pub async fn query_status(socket_path: impl AsRef) -> Result /// Print status in a formatted table. pub fn print_status_table(status: &DaemonStatus) { - println!("rmcp-mux v{} | uptime: {}", status.version, status.uptime); + println!("rust-mux v{} | uptime: {}", status.version, status.uptime); println!("{:─<72}", ""); println!( "{:<20} {:^8} {:>8} {:>8} {:>10} {:>10}", @@ -340,6 +340,11 @@ fn short_status(status: &str) -> &str { mod tests { use super::*; + #[test] + fn default_status_socket_uses_rust_mux_identity() { + assert_eq!(DEFAULT_STATUS_SOCKET, "/tmp/rust-mux.status.sock"); + } + #[test] fn short_status_mapping() { assert_eq!(short_status("Running"), "UP"); diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 8b991d1..f234791 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -9,7 +9,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde_json::Value; use tempfile::tempdir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixListener; +use tokio::net::UnixStream; use tokio::sync::mpsc::{self, UnboundedReceiver}; use tokio::sync::{Mutex, Semaphore, watch}; @@ -17,8 +19,8 @@ use crate::config::{ CliOptions, Config, ResolvedParams, ServerConfig, expand_path, load_config, resolve_params, }; use crate::state::{ - MuxState, Pending, ServerStatus, StatusSnapshot, error_response, publish_status, reset_state, - set_id, snapshot_for_state, + MuxState, MuxStateConfig, Pending, ServerStatus, StatusSnapshot, error_response, + publish_status, reset_state, set_id, snapshot_for_state, }; use super::client::handle_client_message; @@ -113,17 +115,17 @@ impl CliOptions for TestCli { } fn test_state_with_max(max: usize) -> Arc> { - Arc::new(Mutex::new(MuxState::new( - max, - "test".into(), - 1_048_576, - Duration::from_secs(30), - Duration::from_secs(1), - Duration::from_secs(30), - 5, - 0, - None, - ))) + Arc::new(Mutex::new(MuxState::new(MuxStateConfig { + max_active_clients: max, + service_name: "test".into(), + max_request_bytes: 1_048_576, + request_timeout: Duration::from_secs(30), + restart_backoff: Duration::from_secs(1), + restart_backoff_max: Duration::from_secs(30), + max_restarts: 5, + queue_depth: 0, + child_pid: None, + }))) } fn test_state() -> Arc> { @@ -141,7 +143,7 @@ fn tmp_path(name: &str) -> PathBuf { .duration_since(UNIX_EPOCH) .expect("time went backwards") .as_nanos(); - env::temp_dir().join(format!("{}-{}", name, nanos)) + PathBuf::from("target/test-tmp").join(format!("{}-{}", name, nanos)) } fn params_with_socket(socket: PathBuf) -> ResolvedParams { @@ -149,7 +151,8 @@ fn params_with_socket(socket: PathBuf) -> ResolvedParams { socket, cmd: "echo".into(), args: vec![], - env: HashMap::new(), + cwd: None, + env: Some(HashMap::new()), max_clients: 5, tray_enabled: false, log_level: "info".into(), @@ -305,12 +308,9 @@ async fn reset_state_broadcasts_errors() { #[test] fn expand_path_expands_home() { - let home = tmp_path("home-test"); - fs::create_dir_all(&home).expect("create home temp dir"); - // SAFETY: Test runs single-threaded, no concurrent access to env - unsafe { env::set_var("HOME", &home) }; + let home = std::env::var_os("HOME").expect("HOME should be set for path expansion tests"); let expanded = expand_path("~/socket.sock"); - assert!(expanded.starts_with(&home)); + assert_eq!(expanded, PathBuf::from(home).join("socket.sock")); } #[test] @@ -369,6 +369,7 @@ fn resolve_params_overrides_from_config() { socket: Some("/tmp/override.sock".into()), cmd: Some("npx".into()), args: Some(vec!["@mcp".into()]), + cwd: None, env: None, max_active_clients: Some(7), tray: Some(true), @@ -452,6 +453,7 @@ fn resolve_params_cli_overrides_socket() { socket: Some("/tmp/cfg.sock".into()), cmd: Some("npx".into()), args: None, + cwd: None, env: None, max_active_clients: None, tray: None, @@ -534,6 +536,7 @@ fn resolve_params_prefers_cli_over_config_for_timeouts() { socket: Some("/tmp/s.sock".into()), cmd: Some("npx".into()), args: None, + cwd: None, env: None, max_active_clients: None, tray: None, @@ -803,7 +806,10 @@ async fn status_file_writer_persists_snapshot() { let path = dir.path().join("status.json"); let base = StatusSnapshot { service_name: "svc".into(), + name: "svc".into(), server_status: ServerStatus::Starting, + status_text: "Starting".into(), + level: crate::multi::StatusLevel::Ok, health_status: crate::state::HealthStatus::Starting, restarts: 0, connected_clients: 0, @@ -864,3 +870,195 @@ async fn health_check_fails_for_missing_socket() { "unexpected error: {err}" ); } + +#[tokio::test] +#[ignore = "opcjonalny test roundtrip z lokalnym ~/.cargo/bin/loctree-mcp (uruchamiany przez make test-full)"] +async fn mux_transport_roundtrip_with_loctree_mcp() { + let loctree = expand_path("~/.cargo/bin/loctree-mcp"); + assert!( + loctree.exists(), + "brak binarki referencyjnej: {}", + loctree.display() + ); + + let dir = tempdir().expect("tempdir"); + let socket = dir.path().join("mux-loctree.sock"); + let params = ResolvedParams { + socket: socket.clone(), + cmd: loctree.to_string_lossy().to_string(), + args: vec![], + cwd: None, + env: Some(HashMap::new()), + max_clients: 5, + tray_enabled: false, + log_level: "info".into(), + service_name: "loctree-mcp".into(), + lazy_start: false, + max_request_bytes: 1_048_576, + request_timeout: Duration::from_secs(10), + restart_backoff: Duration::from_millis(200), + restart_backoff_max: Duration::from_secs(2), + max_restarts: 1, + status_file: None, + heartbeat_interval: Duration::from_secs(30), + heartbeat_timeout: Duration::from_secs(30), + heartbeat_max_failures: 3, + heartbeat_enabled: false, + }; + + let shutdown = tokio_util::sync::CancellationToken::new(); + let mux_shutdown = shutdown.clone(); + let mux_task = tokio::spawn(async move { super::run_mux_internal(params, mux_shutdown).await }); + + for _ in 0..100 { + if socket.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + socket.exists(), + "socket muxa nie powstaΕ‚: {}", + socket.display() + ); + + let stream = UnixStream::connect(&socket) + .await + .expect("poΕ‚Δ…czenie do socketu muxa"); + let (read_half, mut write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "rust-mux-test", "version": "0.1.0"} + } + }); + write_half + .write_all( + (serde_json::to_string(&initialize).expect("serialize initialize") + "\n").as_bytes(), + ) + .await + .expect("write initialize"); + + let mut line = String::new(); + let init_response = loop { + line.clear(); + let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line)) + .await + .expect("timeout czytania initialize") + .expect("read initialize"); + assert!(n > 0, "zamkniΔ™te poΕ‚Δ…czenie przed initialize response"); + let json: Value = serde_json::from_str(line.trim()).expect("json initialize response"); + if json.get("id") == Some(&Value::Number(1.into())) { + break json; + } + }; + assert!( + init_response.get("result").is_some(), + "initialize nie zwrΓ³ciΕ‚ result: {init_response}" + ); + + let initialized = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }); + write_half + .write_all( + (serde_json::to_string(&initialized).expect("serialize initialized") + "\n").as_bytes(), + ) + .await + .expect("write initialized notification"); + + let tools_list = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }); + write_half + .write_all( + (serde_json::to_string(&tools_list).expect("serialize tools/list") + "\n").as_bytes(), + ) + .await + .expect("write tools/list"); + + let tools_response = loop { + line.clear(); + let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line)) + .await + .expect("timeout czytania tools/list") + .expect("read tools/list"); + assert!(n > 0, "zamkniΔ™te poΕ‚Δ…czenie przed tools/list response"); + let json: Value = serde_json::from_str(line.trim()).expect("json tools/list response"); + if json.get("id") == Some(&Value::Number(2.into())) { + break json; + } + }; + assert!( + tools_response.get("result").is_some(), + "tools/list nie zwrΓ³ciΕ‚ result: {tools_response}" + ); + + let project_root = env::current_dir() + .expect("current_dir") + .to_string_lossy() + .to_string(); + let repo_view = serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "repo-view", + "arguments": { + "project": project_root + } + } + }); + write_half + .write_all( + (serde_json::to_string(&repo_view).expect("serialize tools/call repo-view") + "\n") + .as_bytes(), + ) + .await + .expect("write tools/call repo-view"); + + let repo_view_response = loop { + line.clear(); + let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line)) + .await + .expect("timeout czytania tools/call repo-view") + .expect("read tools/call repo-view"); + assert!( + n > 0, + "zamkniΔ™te poΕ‚Δ…czenie przed tools/call repo-view response" + ); + let json: Value = + serde_json::from_str(line.trim()).expect("json tools/call repo-view response"); + if json.get("id") == Some(&Value::Number(3.into())) { + break json; + } + }; + let repo_view_result = repo_view_response + .get("result") + .expect("tools/call repo-view bez result"); + println!( + "loctree-mcp repo-view (rust-mux): {}", + serde_json::to_string_pretty(repo_view_result).expect("serialize repo-view result") + ); + + shutdown.cancel(); + let join_result = tokio::time::timeout(Duration::from_secs(5), mux_task) + .await + .expect("timeout na zamkniΔ™cie muxa") + .expect("join mux task"); + assert!( + join_result.is_ok(), + "mux zakoΕ„czyΕ‚ siΔ™ bΕ‚Δ™dem: {join_result:?}" + ); +} diff --git a/src/scan.rs b/src/scan.rs index 69c7a5d..931a798 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,15 +1,17 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use anyhow::{Context, Result, anyhow}; use clap::Args; use serde::{Deserialize, Serialize}; -use crate::config::{Config, ServerConfig, expand_path, safe_copy, safe_read_to_string}; +use crate::config::{Config, ServerConfig, expand_path, safe_copy_file, safe_read_to_string}; -// Re-export shared types -pub use crate::common::{HostFormat, HostKind}; +// ───────────────────────────────────────────────────────────────────────────── +// CLI arg structs (CLI surface) +// ───────────────────────────────────────────────────────────────────────────── #[derive(Args, Debug, Clone)] pub struct ScanArgs { @@ -26,7 +28,7 @@ pub struct ScanArgs { #[arg(long, default_value = "toml")] pub snippet_format: String, /// Socket directory for generated services. - #[arg(long, default_value = "~/.rmcp_servers/rmcp_mux/sockets")] + #[arg(long, default_value = "~/.rmcp-servers/rust-mux/sockets")] pub socket_dir: String, /// Do not write files; print to stdout. #[arg(long, default_value_t = false)] @@ -38,14 +40,14 @@ pub struct RewireArgs { /// Explicit path to host config; otherwise auto-discovery is used. #[arg(long)] pub path: Option, - /// Host kind to target (codex|cursor|vscode|claude|jetbrains). + /// Host kind to target (codex|claude|claude-desktop|junie|gemini|cursor|vscode|jetbrains). #[arg(long)] pub host: Option, /// Socket directory used for proxy args. - #[arg(long, default_value = "~/.rmcp_servers/rmcp_mux/sockets")] + #[arg(long, default_value = "~/.rmcp-servers/rust-mux/sockets")] pub socket_dir: String, /// Proxy command used in rewritten config. - #[arg(long, default_value = "rmcp_mux_proxy")] + #[arg(long, default_value = "rust-mux-proxy")] pub proxy_cmd: String, /// Extra args passed before --socket. #[arg(long, value_delimiter = ' ')] @@ -60,36 +62,165 @@ pub struct StatusArgs { /// Explicit host config path. #[arg(long)] pub path: Option, - /// Host kind (codex|cursor|vscode|claude|jetbrains). + /// Host kind (codex|claude|claude-desktop|junie|gemini|cursor|vscode|jetbrains). #[arg(long)] pub host: Option, - /// Expected proxy command (default rmcp_mux_proxy). - #[arg(long, default_value = "rmcp_mux_proxy")] + /// Expected proxy command (default rust-mux-proxy). + #[arg(long, default_value = "rust-mux-proxy")] pub proxy_cmd: String, } +// ───────────────────────────────────────────────────────────────────────────── +// Core type model: kinds, formats, schemas, confidence, sources +// ───────────────────────────────────────────────────────────────────────────── + +/// Kind of MCP client whose config we are looking at. +/// +/// Reflects the real-world client landscape: Claude has a Code config and +/// a Desktop config that live in different places, Junie ships several +/// possible config locations, Gemini is best driven by its own CLI. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum HostKind { + Codex, + Claude, + ClaudeDesktop, + Junie, + Gemini, + Cursor, + VSCode, + JetBrains, + /// User-provided custom config path. + Custom, + /// Unknown / legacy bucket. + Unknown, +} + +impl HostKind { + pub fn as_label(&self) -> &'static str { + match self { + HostKind::Codex => "codex", + HostKind::Claude => "claude", + HostKind::ClaudeDesktop => "claude-desktop", + HostKind::Junie => "junie", + HostKind::Gemini => "gemini", + HostKind::Cursor => "cursor", + HostKind::VSCode => "vscode", + HostKind::JetBrains => "jetbrains", + HostKind::Custom => "custom", + HostKind::Unknown => "unknown", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + HostKind::Codex => "Codex CLI", + HostKind::Claude => "Claude Code", + HostKind::ClaudeDesktop => "Claude Desktop", + HostKind::Junie => "Junie", + HostKind::Gemini => "Gemini CLI", + HostKind::Cursor => "Cursor", + HostKind::VSCode => "VS Code", + HostKind::JetBrains => "JetBrains IDEs", + HostKind::Custom => "Custom", + HostKind::Unknown => "Unknown", + } + } +} + +/// Wire format used by the host config file. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum HostFormat { + Toml, + Json, +} + +/// Logical schema inside the host config, independent of file format. +/// +/// Different MCP clients store their server map under different keys: +/// `mcpServers` (Claude / Junie / Cursor / VSCode / JetBrains JSON), +/// `servers` (Gemini settings, generic), or `[mcp_servers.]` +/// (Codex TOML, snake_case). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ConfigSchema { + /// JSON object with top-level `mcpServers` map. + McpServersJson, + /// JSON object with top-level `servers` map. + ServersJson, + /// TOML with `[mcp_servers.]` tables (snake_case key). + McpServersToml, + /// JSON: try `mcpServers` first, then `servers`. Used for generic / unknown JSON files. + AutoJson, +} + +/// How sure we are that this path actually represents a real client config. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum Confidence { + High, + Medium, + Low, +} + +/// A single discovered or user-provided client config source. #[derive(Debug, Clone, Serialize)] pub struct HostFile { pub kind: HostKind, pub path: PathBuf, pub format: HostFormat, + /// Logical key/shape inside the file. + pub schema: ConfigSchema, + pub confidence: Confidence, + /// Writable safely if the user opts in to the danger flow. + pub writable: bool, + /// Eligible for the [DANGER] auto-rewrite flow. + /// Some clients (e.g. Gemini) lack a robust strict-config flag and + /// prefer being driven through their own CLI rather than file rewrite. + pub eligible_for_danger: bool, } -#[derive(Debug, Clone, Serialize)] +/// One MCP server discovered inside a client config. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct HostService { pub name: String, pub command: String, pub args: Vec, + pub cwd: Option, pub socket: Option, pub env: Option>, + /// Optional `enabled` flag if the client's schema exposes one. + pub enabled: Option, } +/// Parsed result of one config source. #[derive(Debug, Clone, Serialize)] pub struct ScanResult { pub host: HostFile, pub services: Vec, } +/// Result of merging all discovered services across sources, including +/// any conflicts where the same server name has divergent commands or env. +#[derive(Debug, Clone, Serialize)] +pub struct MergeOutcome { + pub services: Vec, + pub conflicts: Vec, +} + +/// A conflict where the same `name` appears with different `command`/`args`/`env` +/// across two or more sources. +#[derive(Debug, Clone, Serialize)] +pub struct ConflictReport { + pub name: String, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConflictVariant { + pub source_path: PathBuf, + pub source_kind: HostKind, + pub service: HostService, +} + +/// Outcome from rewriting a single host config. #[derive(Debug, Clone, Serialize)] pub struct RewireOutcome { pub path: PathBuf, @@ -97,68 +228,397 @@ pub struct RewireOutcome { pub written: bool, } -#[derive(Deserialize)] -struct RawHostConfig { - #[serde(rename = "mcpServers")] - mcp_servers: Option>, +/// Built-in MCP server source discovered outside a client config file. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum DefaultServerSource { + VibecraftedMcp, +} + +/// A first-class MCP server candidate surfaced by the wizard before any +/// operator-owned client config is rewired. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DiscoveredMcpServer { + pub name: String, + pub source: DefaultServerSource, + pub command: String, + pub args: Vec, + pub cwd: Option, + pub env: Option>, +} + +impl DiscoveredMcpServer { + pub fn into_host_service(self) -> HostService { + HostService { + name: self.name, + command: self.command, + args: self.args, + cwd: self.cwd.map(|p| p.to_string_lossy().into_owned()), + socket: None, + env: self.env, + enabled: Some(true), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Default sources (canonical list per real-world MCP clients) +// ───────────────────────────────────────────────────────────────────────────── + +/// Canonical list of well-known MCP client config sources, exactly as we +/// document them. +/// +/// These are *candidates*: the caller filters down to those that exist on +/// disk via [`discover_hosts`]. +pub fn default_sources() -> Vec { + vec![ + // ───── Claude Code (global JSON config) ───── + HostFile { + kind: HostKind::Claude, + path: expand_path("~/.claude.json"), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + // ───── Claude Desktop (macOS) ───── + HostFile { + kind: HostKind::ClaudeDesktop, + path: expand_path("~/Library/Application Support/Claude/claude_desktop_config.json"), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + // ───── Codex CLI (TOML, snake_case mcp_servers) ───── + HostFile { + kind: HostKind::Codex, + path: expand_path("~/.codex/config.toml"), + format: HostFormat::Toml, + schema: ConfigSchema::McpServersToml, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + // ───── Junie (high-confidence path) ───── + HostFile { + kind: HostKind::Junie, + path: expand_path("~/.junie/mcp/mcp.json"), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + // ───── Junie (generic agent path) ───── + HostFile { + kind: HostKind::Junie, + path: expand_path("~/.agents/mcp.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + HostFile { + kind: HostKind::Junie, + path: expand_path("~/.ai/mcp.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + // ───── Gemini ───── + // Gemini exposes `gemini mcp list/add/remove/enable/disable`. There's + // no observed Claude-style strict config flag, so we discover the + // settings file but mark it as ineligible for the danger rewrite by + // default. The wizard prefers generated `gemini mcp add ...` commands. + HostFile { + kind: HostKind::Gemini, + path: expand_path("~/.gemini/settings.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: false, + }, + // ───── Legacy editor hosts (kept for back-compat with `scan` CLI) ───── + HostFile { + kind: HostKind::Cursor, + path: expand_path("~/Library/Application Support/Cursor/User/settings.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + HostFile { + kind: HostKind::Cursor, + path: expand_path("~/.config/Cursor/User/settings.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + HostFile { + kind: HostKind::VSCode, + path: expand_path("~/Library/Application Support/Code/User/settings.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + HostFile { + kind: HostKind::VSCode, + path: expand_path("~/.config/Code/User/settings.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + HostFile { + kind: HostKind::JetBrains, + path: expand_path("~/Library/Application Support/JetBrains/LLM/mcp.json"), + format: HostFormat::Json, + schema: ConfigSchema::AutoJson, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + }, + ] +} + +/// Discover the local Vibecrafted MCP server through the two supported paths: +/// the framework dev checkout, or an installed `pip show vibecrafted-mcp`. +pub fn discover_vibecrafted_mcp() -> Option { + let dev_path = expand_path("~/Libraxis/vibecrafted/vibecrafted-mcp"); + discover_vibecrafted_mcp_with(&dev_path, pip_show_vibecrafted_mcp) +} + +fn discover_vibecrafted_mcp_with( + dev_path: &Path, + pip_show: impl Fn() -> bool, +) -> Option { + if is_vibecrafted_mcp_project(dev_path) { + return Some(DiscoveredMcpServer { + name: "vibecrafted-mcp".into(), + source: DefaultServerSource::VibecraftedMcp, + command: "python".into(), + args: vec!["-m".into(), "vibecrafted_mcp".into()], + cwd: Some(dev_path.to_path_buf()), + env: None, + }); + } + + if pip_show() { + return Some(DiscoveredMcpServer { + name: "vibecrafted-mcp".into(), + source: DefaultServerSource::VibecraftedMcp, + command: "python".into(), + args: vec!["-m".into(), "vibecrafted_mcp".into()], + cwd: None, + env: None, + }); + } + + None +} + +fn pip_show_vibecrafted_mcp() -> bool { + Command::new("pip") + .args(["show", "vibecrafted-mcp"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn is_vibecrafted_mcp_project(path: &Path) -> bool { + pyproject_name(path) + .as_deref() + .is_some_and(|name| name == "vibecrafted-mcp") +} + +fn pyproject_name(path: &Path) -> Option { + let pyproject = path.join("pyproject.toml"); + let data = fs::read_to_string(pyproject).ok()?; + let root: toml::Value = toml::from_str(&data).ok()?; + root.get("project")? + .get("name")? + .as_str() + .map(ToOwned::to_owned) +} + +/// Filter [`default_sources`] down to candidates that actually exist on disk. +pub fn discover_hosts() -> Vec { + default_sources() + .into_iter() + .filter(|hf| hf.path.exists()) + .collect() +} + +/// Build a custom [`HostFile`] from a user-provided path, inferring format +/// and schema from the extension. The danger-rewrite flag is set to `true` +/// because the user explicitly pointed us at this file. +pub fn host_file_from_custom_path(path: &Path) -> HostFile { + let format = match path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .as_deref() + { + Some("toml") => HostFormat::Toml, + _ => HostFormat::Json, + }; + let schema = match format { + HostFormat::Toml => ConfigSchema::McpServersToml, + HostFormat::Json => ConfigSchema::AutoJson, + }; + HostFile { + kind: HostKind::Custom, + path: path.to_path_buf(), + format, + schema, + confidence: Confidence::Medium, + writable: true, + eligible_for_danger: true, + } } -#[derive(Deserialize)] +// ───────────────────────────────────────────────────────────────────────────── +// Multi-schema parser +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] struct RawServer { command: Option, args: Option>, + cwd: Option, socket: Option, env: Option>, + enabled: Option, } -pub fn discover_hosts() -> Vec { - let mut files = Vec::new(); - let candidates: Vec<(HostKind, HostFormat, PathBuf)> = vec![ - ( - HostKind::Codex, - HostFormat::Toml, - expand_path("~/.codex/config.toml"), - ), - ( - HostKind::Cursor, - HostFormat::Json, - expand_path("~/Library/Application Support/Cursor/User/settings.json"), - ), - ( - HostKind::Cursor, - HostFormat::Json, - expand_path("~/.config/Cursor/User/settings.json"), - ), - ( - HostKind::VSCode, - HostFormat::Json, - expand_path("~/Library/Application Support/Code/User/settings.json"), - ), - ( - HostKind::VSCode, - HostFormat::Json, - expand_path("~/.config/Code/User/settings.json"), - ), - ( - HostKind::Claude, - HostFormat::Json, - expand_path("~/.config/Claude/claude_config.json"), - ), - ( - HostKind::JetBrains, - HostFormat::Json, - expand_path("~/Library/Application Support/JetBrains/LLM/mcp.json"), - ), - ]; - - for (kind, format, path) in candidates { - if path.exists() { - files.push(HostFile { kind, path, format }); +/// Parse a single host config file into a list of MCP services. +/// +/// Dispatches on the file's [`HostFile::schema`]. If the schema is set to +/// [`ConfigSchema::AutoJson`] the parser tries `mcpServers` first, then +/// falls back to `servers` if the shape is clearly an MCP server map. +pub fn scan_host_file(file: &HostFile) -> Result { + let data = safe_read_to_string(&file.path) + .with_context(|| format!("failed to read {}", file.path.display()))?; + let services = match (file.format, file.schema) { + (HostFormat::Json, ConfigSchema::McpServersJson) => { + parse_json_servers_under_key(&data, &file.path, "mcpServers", true)? + } + (HostFormat::Json, ConfigSchema::ServersJson) => { + parse_json_servers_under_key(&data, &file.path, "servers", true)? + } + (HostFormat::Json, ConfigSchema::AutoJson) + | (HostFormat::Json, ConfigSchema::McpServersToml) => { + // `McpServersToml` with JSON format is nonsensical; treat as auto. + let primary = parse_json_servers_under_key(&data, &file.path, "mcpServers", false)?; + if !primary.is_empty() { + primary + } else { + parse_json_servers_under_key(&data, &file.path, "servers", false)? + } + } + (HostFormat::Toml, ConfigSchema::McpServersToml) + | (HostFormat::Toml, ConfigSchema::AutoJson) => parse_toml_mcp_servers(&data, &file.path)?, + (HostFormat::Toml, ConfigSchema::McpServersJson) + | (HostFormat::Toml, ConfigSchema::ServersJson) => { + // Same misalignment guard: TOML never holds JSON-shape `mcpServers`. + parse_toml_mcp_servers(&data, &file.path)? } + }; + + Ok(ScanResult { + host: file.clone(), + services, + }) +} + +fn parse_json_servers_under_key( + data: &str, + path: &Path, + key: &str, + require_present: bool, +) -> Result> { + let root: serde_json::Value = serde_json::from_str(data) + .with_context(|| format!("failed to parse json {}", path.display()))?; + let map = match root.get(key) { + Some(serde_json::Value::Object(m)) => m, + Some(_) => return Err(anyhow!("`{}` is not an object in {}", key, path.display())), + None if require_present => { + return Err(anyhow!("missing `{}` in {}", key, path.display())); + } + None => return Ok(Vec::new()), + }; + + let mut out = Vec::new(); + for (name, raw) in map { + // Skip entries that aren't MCP-shaped (e.g. ide settings under `servers`) + let raw: RawServer = match serde_json::from_value::(raw.clone()) { + Ok(r) if r.command.is_some() => r, + _ => continue, + }; + let Some(command) = raw.command else { continue }; + out.push(HostService { + name: name.clone(), + command, + args: raw.args.unwrap_or_default(), + cwd: raw.cwd, + socket: raw.socket, + env: raw.env, + enabled: raw.enabled, + }); } - files + Ok(out) } +fn parse_toml_mcp_servers(data: &str, path: &Path) -> Result> { + let root: toml::Value = + toml::from_str(data).with_context(|| format!("failed to parse toml {}", path.display()))?; + // Codex TOML uses `[mcp_servers.]` (snake_case). + let table = match root.get("mcp_servers") { + Some(toml::Value::Table(t)) => t.clone(), + _ => return Ok(Vec::new()), + }; + + let mut out = Vec::new(); + for (name, raw) in table { + let raw_str = + toml::to_string(&raw).with_context(|| format!("re-serialize toml entry {}", name))?; + let parsed: RawServer = match toml::from_str::(&raw_str) { + Ok(r) if r.command.is_some() => r, + _ => continue, + }; + let Some(command) = parsed.command else { + continue; + }; + out.push(HostService { + name: name.clone(), + command, + args: parsed.args.unwrap_or_default(), + cwd: parsed.cwd, + socket: parsed.socket, + env: parsed.env, + enabled: parsed.enabled, + }); + } + Ok(out) +} + +/// Scan all auto-discovered host config files. Failures are logged and the +/// failing source is skipped; callers see only successfully-parsed sources. pub fn scan_hosts() -> Vec { discover_hosts() .into_iter() @@ -172,80 +632,158 @@ pub fn scan_hosts() -> Vec { .collect() } -pub fn format_for_host(host: &HostFile) -> &'static str { - match host.format { - HostFormat::Json => "json", - HostFormat::Toml => "toml", - } -} +// ───────────────────────────────────────────────────────────────────────────── +// Dedup + conflict detection +// ───────────────────────────────────────────────────────────────────────────── -pub fn scan_host_file(file: &HostFile) -> Result { - let data = safe_read_to_string(&file.path)?; - let raw: RawHostConfig = match file.format { - HostFormat::Toml => toml::from_str(&data) - .with_context(|| format!("failed to parse toml {}", file.path.display()))?, - HostFormat::Json => serde_json::from_str(&data) - .with_context(|| format!("failed to parse json {}", file.path.display()))?, - }; +/// Merge services across sources, deduplicating identical entries by +/// `(name, command, args, env)` and surfacing conflicts where the same +/// name has different command/args/env. +/// +/// Conflicting servers are renamed with deterministic `-from-` +/// suffixes so both variants survive the merge. +pub fn merge_services(scans: &[ScanResult]) -> MergeOutcome { + // group by name -> Vec<(source, service)> + let mut by_name: BTreeMap> = BTreeMap::new(); + for scan in scans { + for svc in &scan.services { + by_name + .entry(svc.name.clone()) + .or_default() + .push((scan.host.clone(), svc.clone())); + } + } let mut services = Vec::new(); - if let Some(map) = raw.mcp_servers { - for (name, srv) in map { - let command = srv - .command - .ok_or_else(|| anyhow!("service {name} missing command"))?; - let args = srv.args.unwrap_or_default(); - services.push(HostService { - name, - command, - args, - socket: srv.socket, - env: srv.env, - }); + let mut conflicts = Vec::new(); + let mut used_names: std::collections::HashSet = std::collections::HashSet::new(); + + for (name, mut variants) in by_name { + // Identical entries (same command/args/env) collapse into one. + let mut unique: Vec<(HostFile, HostService)> = Vec::new(); + for (src, svc) in variants.drain(..) { + if !unique + .iter() + .any(|(_, existing)| services_equivalent(existing, &svc)) + { + unique.push((src, svc)); + } + } + + if unique.len() == 1 { + let (_src, svc) = unique.into_iter().next().expect("len 1"); + used_names.insert(name.clone()); + services.push(svc); + continue; + } + + // Multiple distinct variants -> conflict. Surface every variant with + // a deterministic suffix so the operator can keep both. + let report = ConflictReport { + name: name.clone(), + variants: unique + .iter() + .map(|(src, svc)| ConflictVariant { + source_path: src.path.clone(), + source_kind: src.kind, + service: svc.clone(), + }) + .collect(), + }; + conflicts.push(report); + + for (src, svc) in unique { + let candidate = format!("{}-from-{}", name, src.kind.as_label()); + let unique_name = ensure_unique(&used_names, candidate); + used_names.insert(unique_name.clone()); + let mut renamed = svc; + renamed.name = unique_name; + services.push(renamed); } } - Ok(ScanResult { - host: file.clone(), + MergeOutcome { services, - }) + conflicts, + } +} + +fn services_equivalent(a: &HostService, b: &HostService) -> bool { + a.command == b.command + && a.args == b.args + && a.cwd == b.cwd + && env_equal(a.env.as_ref(), b.env.as_ref()) + && a.socket == b.socket +} + +fn env_equal(a: Option<&HashMap>, b: Option<&HashMap>) -> bool { + match (a, b) { + (None, None) => true, + (Some(am), Some(bm)) => am == bm, + _ => false, + } +} + +fn ensure_unique(used: &std::collections::HashSet, candidate: String) -> String { + if !used.contains(&candidate) { + return candidate; + } + let mut counter = 2usize; + loop { + let next = format!("{candidate}-{counter}"); + if !used.contains(&next) { + return next; + } + counter += 1; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Manifest + snippet helpers (used by the legacy `scan` and `rewire` CLIs) +// ───────────────────────────────────────────────────────────────────────────── + +pub fn format_for_host(host: &HostFile) -> &'static str { + match host.format { + HostFormat::Json => "json", + HostFormat::Toml => "toml", + } } pub fn build_manifest(scans: &[ScanResult], socket_dir: &Path) -> Config { + let merged = merge_services(scans); let mut cfg = Config::default(); - for scan in scans { - for svc in &scan.services { - let socket = svc.socket.clone().unwrap_or_else(|| { - socket_dir - .join(format!("{}.sock", svc.name)) - .to_string_lossy() - .to_string() - }); - cfg.servers.insert( - svc.name.clone(), - ServerConfig { - socket: Some(socket), - cmd: Some(svc.command.clone()), - args: Some(svc.args.clone()), - env: None, - max_active_clients: Some(5), - tray: Some(false), - service_name: Some(svc.name.clone()), - log_level: Some("info".into()), - lazy_start: Some(false), - max_request_bytes: Some(1_048_576), - request_timeout_ms: Some(30_000), - restart_backoff_ms: Some(1_000), - restart_backoff_max_ms: Some(30_000), - max_restarts: Some(5), - status_file: None, - heartbeat_interval_ms: Some(30_000), - heartbeat_timeout_ms: Some(30_000), - heartbeat_max_failures: Some(3), - heartbeat_enabled: Some(true), - }, - ); - } + for svc in merged.services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .to_string() + }); + cfg.servers.insert( + svc.name.clone(), + ServerConfig { + socket: Some(socket), + cmd: Some(svc.command.clone()), + args: Some(svc.args.clone()), + cwd: svc.cwd.clone(), + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(svc.name.clone()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + env: svc.env.clone(), + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + ); } cfg } @@ -256,54 +794,61 @@ pub fn generate_snippet( proxy_cmd: &str, proxy_args: &[String], ) -> HashMap { - let mut snippets = HashMap::new(); - + let merged = merge_services(scans); let mut servers = serde_json::Map::new(); - for scan in scans { - for svc in &scan.services { - let socket = svc.socket.clone().unwrap_or_else(|| { - socket_dir - .join(format!("{}.sock", svc.name)) - .to_string_lossy() - .to_string() - }); - let mut args = Vec::new(); - args.extend(proxy_args.to_owned()); - args.push("--socket".to_string()); - args.push(socket); + for svc in &merged.services { + let socket = svc.socket.clone().unwrap_or_else(|| { + socket_dir + .join(format!("{}.sock", svc.name)) + .to_string_lossy() + .to_string() + }); + let mut args: Vec = proxy_args.to_owned(); + args.push("--socket".to_string()); + args.push(socket); - let mut server = serde_json::Map::new(); - server.insert( - "command".to_string(), - serde_json::Value::String(proxy_cmd.to_string()), - ); + let mut server = serde_json::Map::new(); + server.insert( + "command".to_string(), + serde_json::Value::String(proxy_cmd.to_string()), + ); + server.insert( + "args".to_string(), + serde_json::Value::Array(args.into_iter().map(serde_json::Value::String).collect()), + ); + if let Some(env) = &svc.env { server.insert( - "args".to_string(), - serde_json::Value::Array(args.into_iter().map(serde_json::Value::String).collect()), + "env".to_string(), + serde_json::Value::Object( + env.iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(), + ), ); - if let Some(env) = &svc.env { - server.insert( - "env".to_string(), - serde_json::Value::Object( - env.iter() - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect(), - ), - ); - } - servers.insert(svc.name.clone(), serde_json::Value::Object(server)); } - let mut root = serde_json::Map::new(); - root.insert( - "mcpServers".to_string(), - serde_json::Value::Object(servers.clone()), - ); - snippets.insert(scan.host.kind, serde_json::Value::Object(root.clone())); + servers.insert(svc.name.clone(), serde_json::Value::Object(server)); } + let mut root = serde_json::Map::new(); + root.insert("mcpServers".to_string(), serde_json::Value::Object(servers)); + let snippet_value = serde_json::Value::Object(root); + + let mut snippets = HashMap::new(); + for scan in scans { + snippets.insert(scan.host.kind, snippet_value.clone()); + } snippets } +// ───────────────────────────────────────────────────────────────────────────── +// Legacy single-host rewrite (used by `rust-mux rewire` CLI) +// +// Note: the *wizard* uses the safer flow defined in `crate::danger`, which +// always takes timestamped backups, previews changes, and refuses to touch +// invalid files. This function is preserved for the CLI subcommand and for +// callers that already opted in via explicit `--path`. +// ───────────────────────────────────────────────────────────────────────────── + pub fn rewire_host( host: &HostFile, socket_dir: &Path, @@ -318,7 +863,8 @@ pub fn rewire_host( .ok_or_else(|| anyhow!("no snippet generated for host"))?; let format = format_for_host(host); let snippet_text = serialize_snippet(snippet, format)?; - let data = safe_read_to_string(&host.path)?; + let data = safe_read_to_string(&host.path) + .with_context(|| format!("failed to read {}", host.path.display()))?; let merged = match host.format { HostFormat::Json => { @@ -348,7 +894,13 @@ pub fn rewire_host( let table = root .as_table_mut() .ok_or_else(|| anyhow!("host toml must be a table"))?; - table.insert("mcpServers".into(), mcp); + // Use the schema-correct key for TOML clients (Codex expects mcp_servers). + let target_key = if matches!(host.kind, HostKind::Codex) { + "mcp_servers" + } else { + "mcpServers" + }; + table.insert(target_key.into(), mcp); toml::to_string_pretty(&root).context("serialize merged toml")? } }; @@ -361,6 +913,8 @@ pub fn rewire_host( }) } +/// Write `contents` to `path`, creating a `.bak` backup of the previous +/// contents. Used by the legacy `rewire` CLI subcommand. pub fn write_with_backup(path: &Path, contents: &str, dry_run: bool) -> Result> { if dry_run { println!("--- {} (dry-run) ---\n{}", path.display(), contents); @@ -372,7 +926,8 @@ pub fn write_with_backup(path: &Path, contents: &str, dry_run: bool) -> Result Result Result { if let Some(path) = &args.path { - let fmt = match path - .extension() - .and_then(|e| e.to_str()) - .map(|s| s.to_ascii_lowercase()) - .as_deref() - { - Some("toml") => HostFormat::Toml, - _ => HostFormat::Json, - }; - return Ok(HostFile { - kind: HostKind::Unknown, - path: path.clone(), - format: fmt, - }); + return Ok(host_file_from_custom_path(path)); } let discovered = discover_hosts(); @@ -429,20 +971,7 @@ pub fn resolve_host_from_args(args: &RewireArgs) -> Result { pub fn resolve_status_host(args: &StatusArgs) -> Result { if let Some(path) = &args.path { - let fmt = match path - .extension() - .and_then(|e| e.to_str()) - .map(|s| s.to_ascii_lowercase()) - .as_deref() - { - Some("toml") => HostFormat::Toml, - _ => HostFormat::Json, - }; - return Ok(HostFile { - kind: HostKind::Unknown, - path: path.clone(), - format: fmt, - }); + return Ok(host_file_from_custom_path(path)); } let discovered = discover_hosts(); if discovered.is_empty() { @@ -489,7 +1018,7 @@ pub fn run_scan_cmd(args: ScanArgs) -> Result<()> { } if args.snippet.is_some() || args.dry_run { - let snippets = generate_snippet(&scans, &socket_dir, "rmcp_mux_proxy", &[]); + let snippets = generate_snippet(&scans, &socket_dir, "rust-mux-proxy", &[]); for (kind, snippet) in snippets { let fmt = args.snippet_format.to_lowercase(); let text = serialize_snippet(&snippet, &fmt)?; @@ -570,54 +1099,342 @@ pub fn run_status_cmd(args: StatusArgs) -> Result<()> { Ok(()) } +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; - fn write_json(path: &Path, body: &serde_json::Value) { - fs::create_dir_all(path.parent().expect("json parent dir")).expect("create parent"); - let json = serde_json::to_string_pretty(body).expect("encode json"); - fs::write(path, json).expect("write json"); + fn write_text(path: &Path, body: &str) { + fs::create_dir_all(path.parent().expect("parent dir")).expect("create parent"); + fs::write(path, body).expect("write file"); + } + + fn json_host(path: PathBuf, kind: HostKind, schema: ConfigSchema) -> HostFile { + HostFile { + kind, + path, + format: HostFormat::Json, + schema, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + fn toml_host(path: PathBuf, kind: HostKind) -> HostFile { + HostFile { + kind, + path, + format: HostFormat::Toml, + schema: ConfigSchema::McpServersToml, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } } #[test] - fn scan_host_file_reads_json() { + fn defaults_cover_required_clients() { + let kinds: std::collections::HashSet = + default_sources().iter().map(|s| s.kind).collect(); + assert!(kinds.contains(&HostKind::Claude), "Claude Code missing"); + assert!( + kinds.contains(&HostKind::ClaudeDesktop), + "Claude Desktop missing" + ); + assert!(kinds.contains(&HostKind::Codex), "Codex missing"); + assert!(kinds.contains(&HostKind::Junie), "Junie missing"); + assert!(kinds.contains(&HostKind::Gemini), "Gemini missing"); + } + + #[test] + fn defaults_use_canonical_paths() { + let by_kind: std::collections::HashMap> = default_sources() + .iter() + .fold(Default::default(), |mut acc, s| { + acc.entry(s.kind).or_default().push(s.path.clone()); + acc + }); + + let claude_paths = by_kind.get(&HostKind::Claude).expect("claude"); + assert!( + claude_paths.iter().any(|p| p.ends_with(".claude.json")), + "expected ~/.claude.json among Claude paths, got {:?}", + claude_paths + ); + + let desktop_paths = by_kind + .get(&HostKind::ClaudeDesktop) + .expect("claude desktop"); + assert!( + desktop_paths + .iter() + .any(|p| p.ends_with("claude_desktop_config.json")), + "expected claude_desktop_config.json" + ); + + let codex_paths = by_kind.get(&HostKind::Codex).expect("codex"); + assert!(codex_paths.iter().any(|p| p.ends_with("config.toml"))); + + let junie_paths = by_kind.get(&HostKind::Junie).expect("junie"); + assert!( + junie_paths + .iter() + .any(|p| p.ends_with(".junie/mcp/mcp.json")) + ); + assert!(junie_paths.iter().any(|p| p.ends_with(".agents/mcp.json"))); + assert!(junie_paths.iter().any(|p| p.ends_with(".ai/mcp.json"))); + + let gemini_paths = by_kind.get(&HostKind::Gemini).expect("gemini"); + assert!( + gemini_paths + .iter() + .any(|p| p.ends_with(".gemini/settings.json")) + ); + } + + #[test] + fn discover_vibecrafted_mcp_dev_path_found() { let dir = tempdir().expect("tempdir"); - let path = dir.path().join("settings.json"); - let json = serde_json::json!({ - "mcpServers": { - "memory": {"command": "npx", "args": ["@mcp/server-memory"], "socket": "/tmp/mem.sock"} - } - }); - write_json(&path, &json); + write_text( + &dir.path().join("pyproject.toml"), + r#" + [project] + name = "vibecrafted-mcp" + "#, + ); - let file = HostFile { - kind: HostKind::Codex, - path: path.clone(), - format: HostFormat::Json, - }; - let scan = scan_host_file(&file).expect("scan file"); + let server = discover_vibecrafted_mcp_with(dir.path(), || false).expect("server"); + + assert_eq!(server.name, "vibecrafted-mcp"); + assert_eq!(server.source, DefaultServerSource::VibecraftedMcp); + assert_eq!(server.command, "python"); + assert_eq!(server.args, vec!["-m", "vibecrafted_mcp"]); + assert_eq!(server.cwd.as_deref(), Some(dir.path())); + } + + #[test] + fn discover_vibecrafted_mcp_pip_show_found() { + let dir = tempdir().expect("tempdir"); + let server = discover_vibecrafted_mcp_with(dir.path(), || true).expect("server"); + + assert_eq!(server.name, "vibecrafted-mcp"); + assert_eq!(server.command, "python"); + assert_eq!(server.args, vec!["-m", "vibecrafted_mcp"]); + assert_eq!(server.cwd, None); + } + + #[test] + fn discover_vibecrafted_mcp_returns_none_when_neither() { + let dir = tempdir().expect("tempdir"); + write_text( + &dir.path().join("pyproject.toml"), + r#" + [project] + name = "something-else" + "#, + ); + + assert!(discover_vibecrafted_mcp_with(dir.path(), || false).is_none()); + } + + #[test] + fn parse_json_mcpservers_for_claude() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("claude.json"); + write_text( + &path, + r#"{ + "other": true, + "mcpServers": { + "memory": { + "command": "npx", + "args": ["@modelcontextprotocol/server-memory"], + "env": {"FOO": "bar"} + } + } + }"#, + ); + + let host = json_host(path, HostKind::Claude, ConfigSchema::McpServersJson); + let scan = scan_host_file(&host).expect("scan"); assert_eq!(scan.services.len(), 1); assert_eq!(scan.services[0].name, "memory"); assert_eq!(scan.services[0].command, "npx"); - assert_eq!(scan.services[0].socket.as_deref(), Some("/tmp/mem.sock")); + assert_eq!( + scan.services[0] + .env + .as_ref() + .and_then(|m| m.get("FOO")) + .map(|s| s.as_str()), + Some("bar") + ); + } + + #[test] + fn parse_toml_mcp_servers_for_codex() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + write_text( + &path, + r#" + [other] + unrelated = "keep me" + + [mcp_servers.memory] + command = "npx" + args = ["@modelcontextprotocol/server-memory"] + + [mcp_servers.memory.env] + HOME = "/tmp" + "#, + ); + + let host = toml_host(path, HostKind::Codex); + let scan = scan_host_file(&host).expect("scan"); + assert_eq!(scan.services.len(), 1); + let svc = &scan.services[0]; + assert_eq!(svc.name, "memory"); + assert_eq!(svc.command, "npx"); + assert_eq!(svc.args, vec!["@modelcontextprotocol/server-memory"]); + assert_eq!( + svc.env + .as_ref() + .and_then(|m| m.get("HOME")) + .map(String::as_str), + Some("/tmp") + ); + } + + #[test] + fn parse_junie_json_mcpservers() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("mcp.json"); + write_text( + &path, + r#"{"mcpServers": {"fs": {"command": "npx", "args": ["@modelcontextprotocol/server-filesystem", "/tmp"]}}}"#, + ); + + let host = json_host(path, HostKind::Junie, ConfigSchema::McpServersJson); + let scan = scan_host_file(&host).expect("scan"); + assert_eq!(scan.services.len(), 1); + assert_eq!(scan.services[0].name, "fs"); + } + + #[test] + fn parse_generic_json_servers_when_present() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("custom.json"); + write_text( + &path, + r#"{"servers": {"echo": {"command": "echo", "args": ["hi"]}}}"#, + ); + + let host = json_host(path, HostKind::Custom, ConfigSchema::AutoJson); + let scan = scan_host_file(&host).expect("scan"); + assert_eq!(scan.services.len(), 1); + assert_eq!(scan.services[0].command, "echo"); + } + + #[test] + fn merge_dedupes_identical_services() { + let dir = tempdir().expect("tempdir"); + let path_a = dir.path().join("a.json"); + let path_b = dir.path().join("b.json"); + + let svc = HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }; + let scans = vec![ + ScanResult { + host: json_host(path_a, HostKind::Claude, ConfigSchema::McpServersJson), + services: vec![svc.clone()], + }, + ScanResult { + host: json_host( + path_b, + HostKind::ClaudeDesktop, + ConfigSchema::McpServersJson, + ), + services: vec![svc], + }, + ]; + + let merged = merge_services(&scans); + assert_eq!( + merged.services.len(), + 1, + "identical entries should collapse" + ); + assert!(merged.conflicts.is_empty()); + } + + #[test] + fn merge_surfaces_conflicting_services() { + let dir = tempdir().expect("tempdir"); + let path_a = dir.path().join("a.json"); + let path_b = dir.path().join("b.json"); + + let scans = vec![ + ScanResult { + host: json_host(path_a, HostKind::Claude, ConfigSchema::McpServersJson), + services: vec![HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }, + ScanResult { + host: json_host(path_b, HostKind::Junie, ConfigSchema::McpServersJson), + services: vec![HostService { + name: "memory".into(), + command: "uv".into(), + args: vec!["run".into(), "memory-server".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }, + ]; + + let merged = merge_services(&scans); + assert_eq!( + merged.conflicts.len(), + 1, + "expected one conflict report for `memory`" + ); + let names: Vec<&str> = merged.services.iter().map(|s| s.name.as_str()).collect(); + assert!(names.iter().any(|n| n.starts_with("memory-from-claude"))); + assert!(names.iter().any(|n| n.starts_with("memory-from-junie"))); } #[test] fn build_manifest_populates_defaults() { let scans = vec![ScanResult { - host: HostFile { - kind: HostKind::Codex, - path: PathBuf::from("dummy"), - format: HostFormat::Toml, - }, + host: toml_host(PathBuf::from("dummy"), HostKind::Codex), services: vec![HostService { name: "memory".into(), command: "npx".into(), args: vec!["@mcp/server-memory".into()], + cwd: None, socket: None, env: None, + enabled: None, }], }]; let cfg = build_manifest(&scans, Path::new("/tmp/sockets")); @@ -638,20 +1455,24 @@ mod tests { #[test] fn generate_snippet_uses_proxy() { let scans = vec![ScanResult { - host: HostFile { - kind: HostKind::Codex, - path: PathBuf::from("dummy"), - format: HostFormat::Toml, - }, + host: toml_host(PathBuf::from("dummy"), HostKind::Codex), services: vec![HostService { name: "svc".into(), command: "npx".into(), args: vec!["x".into()], + cwd: None, socket: None, env: None, + enabled: None, }], }]; - let snippets = generate_snippet(&scans, Path::new("/s"), "rmcp_mux", &["proxy".into()]); + + let snippets = generate_snippet( + &scans, + Path::new("/tmp/sockets"), + "rust-mux-proxy", + &["proxy".into()], + ); let node = snippets.get(&HostKind::Codex).expect("codex snippet"); let servers = node .get("mcpServers") @@ -662,7 +1483,7 @@ mod tests { .expect("svc entry") .as_object() .expect("svc object"); - assert_eq!(svc.get("command").expect("command"), "rmcp_mux"); + assert_eq!(svc.get("command").expect("command"), "rust-mux-proxy"); let args = svc .get("args") .expect("args") @@ -672,29 +1493,23 @@ mod tests { .map(|v| v.as_str().expect("string").to_string()) .collect::>(); assert!(args.contains(&"proxy".to_string())); + assert!(args.iter().any(|s| s == "--socket")); } #[test] fn rewire_updates_mcpservers_in_json() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("settings.json"); - let json = serde_json::json!({ - "other": true, - "mcpServers": { - "memory": {"command": "npx", "args": ["@mcp/server-memory"]} - } - }); - write_json(&path, &json); - let host = HostFile { - kind: HostKind::Codex, - path: path.clone(), - format: HostFormat::Json, - }; + write_text( + &path, + r#"{"other": true, "mcpServers": {"memory": {"command": "npx", "args": ["@mcp/server-memory"]}}}"#, + ); + let host = json_host(path.clone(), HostKind::Claude, ConfigSchema::McpServersJson); rewire_host( &host, Path::new("/tmp/sockets"), - "rmcp_mux", - &["proxy".into()], + "rust-mux-proxy", + &[], false, ) .expect("rewire"); @@ -709,6 +1524,17 @@ mod tests { .expect("memory") .as_object() .expect("memory obj"); - assert_eq!(mem.get("command").expect("command"), "rmcp_mux"); + assert_eq!(mem.get("command").expect("command"), "rust-mux-proxy"); + assert_eq!(updated.get("other").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn host_file_from_custom_path_infers_format() { + let json = host_file_from_custom_path(Path::new("/tmp/custom.json")); + assert_eq!(json.format, HostFormat::Json); + let toml = host_file_from_custom_path(Path::new("/tmp/custom.toml")); + assert_eq!(toml.format, HostFormat::Toml); + assert_eq!(toml.kind, HostKind::Custom); + assert!(toml.eligible_for_danger); } } diff --git a/src/state.rs b/src/state.rs index 4432c73..e28d767 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,98 +5,43 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::{Mutex, Semaphore, mpsc, watch}; -use tokio_util::sync::CancellationToken; - -/// Server startup mode. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum ServerMode { - /// Start server immediately when mux starts. - #[default] - Eager, - /// Start server only on first client connection. - Lazy, -} - -/// State for managing multiple mux servers in a single process. -pub struct MultiMuxState { - /// Per-server state keyed by service name. - pub servers: HashMap>>, - /// Global shutdown token. - pub shutdown: CancellationToken, - /// When the multi-mux runtime started. - pub start_time: Instant, -} - -impl MultiMuxState { - pub fn new(shutdown: CancellationToken) -> Self { - Self { - servers: HashMap::new(), - shutdown, - start_time: Instant::now(), - } - } - - pub fn add_server(&mut self, name: String, state: Arc>) { - self.servers.insert(name, state); - } - - pub fn uptime(&self) -> Duration { - self.start_time.elapsed() - } -} #[cfg_attr(not(feature = "tray"), allow(dead_code))] -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum ServerStatus { Starting, Running, Restarting, Failed(String), Stopped, - /// Server not yet started due to lazy_start=true Lazy, - /// In exponential backoff after crash Backoff, } -/// Health status for dashboard display. -#[cfg_attr(not(feature = "tray"), allow(dead_code))] -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum HealthStatus { - Ok, - Warn, - Error, - Lazy, - Starting, - Backoff, -} +pub type HealthStatus = ServerStatus; -/// Metrics collected by the heartbeat inspector. -/// -/// These metrics are exposed in StatusSnapshot for monitoring and alerting. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct HeartbeatMetrics { - /// Timestamp of the last successful heartbeat (Unix millis). - pub last_heartbeat_ms: Option, - /// Average response time in milliseconds (rolling window). - pub avg_response_ms: Option, - /// Current consecutive failure count. - pub consecutive_failures: u32, - /// Total successful heartbeats since start. - pub total_success: u64, - /// Total failed heartbeats since start. - pub total_failures: u64, - /// Whether heartbeat monitoring is enabled. - pub enabled: bool, +use crate::multi::StatusLevel; + +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonStatus { + pub servers: Vec, + pub version: String, + pub uptime: String, + pub server_count: usize, + pub running_count: usize, + pub error_count: usize, } #[cfg_attr(not(feature = "tray"), allow(dead_code))] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StatusSnapshot { pub service_name: String, + pub name: String, // Alias for service_name pub server_status: ServerStatus, - /// Computed health status for dashboard display - pub health_status: HealthStatus, + pub status_text: String, + pub level: StatusLevel, pub restarts: u64, pub connected_clients: usize, pub active_clients: usize, @@ -108,17 +53,21 @@ pub struct StatusSnapshot { pub queue_depth: usize, pub child_pid: Option, pub max_request_bytes: usize, - pub restart_backoff_ms: u64, - pub restart_backoff_max_ms: u64, - pub max_restarts: u64, - /// Heartbeat health metrics. + pub health_status: HealthStatus, + pub heartbeat_latency_ms: Option, pub heartbeat: HeartbeatMetrics, - /// Server uptime since last start (milliseconds) pub uptime_ms: u64, - /// Whether server is currently in exponential backoff pub in_backoff: bool, - /// Current heartbeat latency in milliseconds - pub heartbeat_latency_ms: Option, + pub restart_backoff_ms: u64, + pub restart_backoff_max_ms: u64, + pub max_restarts: u64, +} +#[derive(Clone, Debug)] +pub struct Pending { + pub client_id: u64, + pub local_id: serde_json::Value, + pub is_initialize: bool, + pub started_at: std::time::Instant, } /// Central runtime state shared between the async mux loops. @@ -138,9 +87,6 @@ pub struct MuxState { pub cached_initialize: Option, pub init_waiting: Vec<(u64, Value)>, pub initializing: bool, - /// True after first notifications/initialized was forwarded to backend server. - /// Subsequent clients' initialized notifications should NOT be forwarded. - pub server_initialized: bool, pub server_status: ServerStatus, pub restarts: u64, pub last_reset: Option, @@ -153,75 +99,47 @@ pub struct MuxState { pub max_restarts: u64, pub queue_depth: usize, pub child_pid: Option, - /// Per-client handshake state for MCP protocol tolerance. - /// Tracks handshake progress to buffer out-of-order messages. - pub client_handshakes: HashMap, - /// Heartbeat health metrics for backend monitoring. pub heartbeat_metrics: HeartbeatMetrics, - /// When the server was last started (for uptime calculation) + pub client_handshakes: HashMap, + pub server_initialized: bool, pub started_at: Option, - /// Whether we're currently in backoff waiting before restart pub in_backoff: bool, } -#[derive(Clone, Debug)] -pub struct Pending { - pub client_id: u64, - pub local_id: Value, - pub is_initialize: bool, - pub started_at: std::time::Instant, +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct HeartbeatMetrics { + pub enabled: bool, + pub latency_ms: Option, + pub last_heartbeat_ms: Option, + pub avg_response_ms: Option, + pub total_success: u64, + pub total_failures: u64, + pub consecutive_failures: u32, } -/// Handshake state for a single client connection. -/// -/// Tracks MCP protocol handshake progress to ensure proper message ordering. -/// Claude Code and other clients sometimes send `tools/list` before completing -/// the initialize handshake, which crashes rmcp-based backends. #[derive(Clone, Debug)] -pub struct ClientHandshakeState { - /// Whether the client has completed the full handshake sequence. - pub handshake_complete: bool, - /// Whether we're waiting for initialize response from server. +pub struct ClientHandshake { + pub connected_at: Instant, + pub initialized: bool, pub initialize_pending: bool, - /// Messages buffered while waiting for handshake to complete. pub buffered_messages: Vec, - /// When the handshake started (for timeout tracking). - pub started_at: Instant, } -impl ClientHandshakeState { - pub fn new() -> Self { - Self { - handshake_complete: false, - initialize_pending: false, - buffered_messages: Vec::new(), - started_at: Instant::now(), - } - } -} - -impl Default for ClientHandshakeState { - fn default() -> Self { - Self::new() - } +#[derive(Clone, Debug)] +pub struct MuxStateConfig { + pub max_active_clients: usize, + pub service_name: String, + pub max_request_bytes: usize, + pub request_timeout: Duration, + pub restart_backoff: Duration, + pub restart_backoff_max: Duration, + pub max_restarts: u64, + pub queue_depth: usize, + pub child_pid: Option, } -/// Handshake timeout duration (10 seconds). -pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); - impl MuxState { - #[allow(clippy::too_many_arguments)] - pub fn new( - max_active_clients: usize, - service_name: String, - max_request_bytes: usize, - request_timeout: Duration, - restart_backoff: Duration, - restart_backoff_max: Duration, - max_restarts: u64, - queue_depth: usize, - child_pid: Option, - ) -> Self { + pub fn new(config: MuxStateConfig) -> Self { Self { next_client_id: 1, next_global_id: 1, @@ -230,21 +148,21 @@ impl MuxState { cached_initialize: None, init_waiting: Vec::new(), initializing: false, - server_initialized: false, server_status: ServerStatus::Starting, restarts: 0, last_reset: None, - max_active_clients, - service_name, - max_request_bytes, - request_timeout, - restart_backoff, - restart_backoff_max, - max_restarts, - queue_depth, - child_pid, - client_handshakes: HashMap::new(), + max_active_clients: config.max_active_clients, + service_name: config.service_name, + max_request_bytes: config.max_request_bytes, + request_timeout: config.request_timeout, + restart_backoff: config.restart_backoff, + restart_backoff_max: config.restart_backoff_max, + max_restarts: config.max_restarts, + queue_depth: config.queue_depth, + child_pid: config.child_pid, heartbeat_metrics: HeartbeatMetrics::default(), + client_handshakes: HashMap::new(), + server_initialized: false, started_at: None, in_backoff: false, } @@ -254,8 +172,15 @@ impl MuxState { let id = self.next_client_id; self.next_client_id += 1; self.clients.insert(id, tx); - self.client_handshakes - .insert(id, ClientHandshakeState::new()); + self.client_handshakes.insert( + id, + ClientHandshake { + connected_at: Instant::now(), + initialized: false, + initialize_pending: false, + buffered_messages: Vec::new(), + }, + ); id } @@ -266,52 +191,47 @@ impl MuxState { self.init_waiting.retain(|(cid, _)| *cid != client_id); } - /// Get mutable reference to client handshake state. - pub fn get_handshake_mut(&mut self, client_id: u64) -> Option<&mut ClientHandshakeState> { - self.client_handshakes.get_mut(&client_id) - } - - /// Check if client handshake is complete. - pub fn is_handshake_complete(&self, client_id: u64) -> bool { - self.client_handshakes - .get(&client_id) - .map(|h| h.handshake_complete) - .unwrap_or(false) + pub fn next_request_id(&mut self) -> u64 { + let id = self.next_global_id; + self.next_global_id += 1; + id } - /// Mark client handshake as complete and return buffered messages. - pub fn complete_handshake(&mut self, client_id: u64) -> Vec { - if let Some(h) = self.client_handshakes.get_mut(&client_id) { - h.handshake_complete = true; - h.initialize_pending = false; - std::mem::take(&mut h.buffered_messages) + pub fn mark_handshake_complete(&mut self, client_id: u64) -> Vec { + if let Some(handshake) = self.client_handshakes.get_mut(&client_id) { + handshake.initialized = true; + std::mem::take(&mut handshake.buffered_messages) } else { Vec::new() } } - /// Buffer a message for a client that hasn't completed handshake. - pub fn buffer_message(&mut self, client_id: u64, msg: Value) -> bool { - if let Some(h) = self.client_handshakes.get_mut(&client_id) { - h.buffered_messages.push(msg); - true - } else { - false - } + pub fn complete_handshake(&mut self, client_id: u64) -> Vec { + self.mark_handshake_complete(client_id) + } + + pub fn is_handshake_complete(&self, client_id: u64) -> bool { + self.client_handshakes + .get(&client_id) + .is_none_or(|handshake| handshake.initialized) } - /// Check if client handshake has timed out. pub fn is_handshake_timed_out(&self, client_id: u64) -> bool { self.client_handshakes .get(&client_id) - .map(|h| !h.handshake_complete && h.started_at.elapsed() > HANDSHAKE_TIMEOUT) - .unwrap_or(false) + .is_some_and(|handshake| { + !handshake.initialized && handshake.connected_at.elapsed() > HANDSHAKE_TIMEOUT + }) } - pub fn next_request_id(&mut self) -> u64 { - let id = self.next_global_id; - self.next_global_id += 1; - id + pub fn buffer_message(&mut self, client_id: u64, msg: Value) { + if let Some(handshake) = self.client_handshakes.get_mut(&client_id) { + handshake.buffered_messages.push(msg); + } + } + + pub fn get_handshake_mut(&mut self, client_id: u64) -> Option<&mut ClientHandshake> { + self.client_handshakes.get_mut(&client_id) } } @@ -335,7 +255,10 @@ pub fn error_response(id: Value, message: &str) -> Value { pub fn snapshot_for_state(st: &MuxState, active_clients: usize) -> StatusSnapshot { StatusSnapshot { service_name: st.service_name.clone(), + name: st.service_name.clone(), server_status: st.server_status.clone(), + status_text: format!("{:?}", st.server_status), + level: StatusLevel::Ok, restarts: st.restarts, connected_clients: st.clients.len(), active_clients, @@ -347,17 +270,17 @@ pub fn snapshot_for_state(st: &MuxState, active_clients: usize) -> StatusSnapsho queue_depth: st.queue_depth, child_pid: st.child_pid, max_request_bytes: st.max_request_bytes, - restart_backoff_ms: st.restart_backoff.as_millis() as u64, - restart_backoff_max_ms: st.restart_backoff_max.as_millis() as u64, - max_restarts: st.max_restarts, + health_status: st.server_status.clone(), + heartbeat_latency_ms: st.heartbeat_metrics.latency_ms, heartbeat: st.heartbeat_metrics.clone(), - health_status: compute_health_status(st, active_clients), uptime_ms: st .started_at .map(|t| t.elapsed().as_millis() as u64) .unwrap_or(0), in_backoff: st.in_backoff, - heartbeat_latency_ms: st.heartbeat_metrics.avg_response_ms, + restart_backoff_ms: st.restart_backoff.as_millis() as u64, + restart_backoff_max_ms: st.restart_backoff_max.as_millis() as u64, + max_restarts: st.max_restarts, } } @@ -386,14 +309,13 @@ pub async fn reset_state( let waiters = std::mem::take(&mut st.init_waiting); st.cached_initialize = None; st.initializing = false; - st.server_initialized = false; st.last_reset = Some(reason.to_string()); st.queue_depth = 0; st.child_pid = None; for (_, p) in pending { if let Some(tx) = st.clients.get(&p.client_id) { - tx.send(error_response(p.local_id, reason)).ok(); + tx.send(error_response(p.local_id.clone(), reason)).ok(); } } for (cid, lid) in waiters { @@ -405,58 +327,27 @@ pub async fn reset_state( publish_status(state, active_clients, status_tx).await; } -/// Compute health status from MuxState. -fn compute_health_status(st: &MuxState, active_clients: usize) -> HealthStatus { - match &st.server_status { - ServerStatus::Running => { - let load_pct = if st.max_active_clients > 0 { - (active_clients * 100) / st.max_active_clients - } else { - 0 - }; - if st.heartbeat_metrics.consecutive_failures > 0 { - HealthStatus::Error - } else if load_pct > 80 || st.restarts > 0 { - HealthStatus::Warn - } else { - HealthStatus::Ok - } - } - ServerStatus::Starting => HealthStatus::Starting, - ServerStatus::Restarting => HealthStatus::Warn, - ServerStatus::Failed(_) => HealthStatus::Error, - ServerStatus::Stopped | ServerStatus::Lazy => HealthStatus::Lazy, - ServerStatus::Backoff => HealthStatus::Backoff, - } -} - #[cfg(test)] mod tests { use super::*; #[test] fn next_request_id_increments_sequentially() { - let mut state = MuxState::new( - 5, - "test-service".into(), - 1_048_576, - Duration::from_secs(30), - Duration::from_millis(1_000), - Duration::from_millis(30_000), - 5, - 0, - None, - ); + let mut state = MuxState::new(MuxStateConfig { + max_active_clients: 5, + service_name: "test-service".into(), + max_request_bytes: 1_048_576, + request_timeout: Duration::from_secs(30), + restart_backoff: Duration::from_millis(1_000), + restart_backoff_max: Duration::from_millis(30_000), + max_restarts: 5, + queue_depth: 0, + child_pid: None, + }); let first = state.next_request_id(); let second = state.next_request_id(); assert_eq!(first + 1, second); } - - #[test] - fn error_response_uses_jsonrpc_2_0() { - let resp = error_response(Value::Number(1.into()), "oops"); - assert_eq!(resp.get("jsonrpc"), Some(&Value::String("2.0".into()))); - } } diff --git a/src/tray.rs b/src/tray.rs index de32af9..81b2847 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -15,13 +15,13 @@ use tray_icon::{ use crate::state::{ServerStatus, StatusSnapshot}; #[derive(Clone, Debug)] -pub(crate) struct LoadedIcon { +pub struct LoadedIcon { pub data: Vec, pub width: u32, pub height: u32, } -pub(crate) fn spawn_tray( +pub fn spawn_tray( status_rx: tokio::sync::watch::Receiver, shutdown: CancellationToken, icon: Option, @@ -62,7 +62,7 @@ fn send_latest(tx: &Sender, snap: StatusSnapshot) { } } -struct TrayUi { +pub struct TrayUi { _tray: tray_icon::TrayIcon, header: MenuItem, status: MenuItem, @@ -158,7 +158,7 @@ fn build_tray(snapshot: &StatusSnapshot, icon_data: Option<&LoadedIcon>) -> Resu default_icon() }; let tray = TrayIconBuilder::new() - .with_tooltip(format!("rmcp_mux – {}", snapshot.service_name)) + .with_tooltip(format!("rust-mux – {}", snapshot.service_name)) .with_icon(icon) .with_menu(Box::new(menu.clone())) .build()?; @@ -181,7 +181,7 @@ fn status_line(snapshot: &StatusSnapshot) -> String { ServerStatus::Running => "Running".to_string(), ServerStatus::Restarting => "Restarting".to_string(), ServerStatus::Stopped => "Stopped".to_string(), - ServerStatus::Lazy => "Lazy (waiting)".to_string(), + ServerStatus::Lazy => "Lazy".to_string(), ServerStatus::Backoff => "Backoff".to_string(), ServerStatus::Failed(reason) => format!("Failed: {reason}"), }; @@ -232,13 +232,11 @@ fn default_icon() -> Icon { Icon::from_rgba(data, w as u32, h as u32).expect("valid icon") } -pub(crate) fn find_tray_icon() -> Option { +pub fn find_tray_icon() -> Option { let candidates = [ - PathBuf::from("public/rmcp_mux_icon.png"), - std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.join("public/rmcp_mux_icon.png"))) - .unwrap_or_else(|| PathBuf::from("public/rmcp_mux_icon.png")), + PathBuf::from("public/icon.png"), + PathBuf::from("../public/icon.png"), + PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/public/icon.png")), ]; for path in candidates { @@ -249,7 +247,7 @@ pub(crate) fn find_tray_icon() -> Option { None } -pub(crate) fn load_icon_from_file(path: &Path) -> Option { +pub fn load_icon_from_file(path: &Path) -> Option { let data = std::fs::read(path).ok()?; let img = image::load_from_memory_with_format(&data, ImageFormat::Png).ok()?; let rgba = img.to_rgba8(); @@ -264,6 +262,7 @@ pub(crate) fn load_icon_from_file(path: &Path) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::multi::StatusLevel; use std::time::{SystemTime, UNIX_EPOCH}; fn tmp_path(name: &str) -> PathBuf { @@ -271,7 +270,7 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("time went backwards") .as_nanos(); - std::env::temp_dir().join(format!("{}-{}", name, nanos)) + PathBuf::from("target/test-tmp").join(format!("{}-{}", name, nanos)) } #[test] @@ -292,8 +291,10 @@ mod tests { fn status_and_lines_render() { let base = StatusSnapshot { service_name: "s".into(), + name: "s".into(), server_status: ServerStatus::Starting, - health_status: crate::state::HealthStatus::Starting, + status_text: "Starting".into(), + level: StatusLevel::Ok, restarts: 0, connected_clients: 1, active_clients: 1, @@ -305,12 +306,13 @@ mod tests { queue_depth: 0, child_pid: None, max_request_bytes: 1_048_576, - restart_backoff_ms: 1_000, - restart_backoff_max_ms: 30_000, - max_restarts: 5, + health_status: ServerStatus::Starting, heartbeat: crate::state::HeartbeatMetrics::default(), uptime_ms: 0, in_backoff: false, + restart_backoff_ms: 1_000, + restart_backoff_max_ms: 30_000, + max_restarts: 5, heartbeat_latency_ms: None, }; assert!(status_line(&base).contains("Starting")); diff --git a/src/tray_dashboard.rs b/src/tray_dashboard.rs index da99a78..815278a 100644 --- a/src/tray_dashboard.rs +++ b/src/tray_dashboard.rs @@ -1,4 +1,4 @@ -//! Multi-server tray dashboard for rmcp_mux. +//! Multi-server tray dashboard for rust-mux. //! //! Displays status of all managed MCP servers in a system tray menu. //! Queries the daemon status socket periodically to update the display. @@ -15,13 +15,36 @@ use tray_icon::{ }; use crate::multi::StatusLevel; -use crate::runtime::{DaemonStatus, DEFAULT_STATUS_SOCKET, query_status}; +use crate::runtime::{DEFAULT_STATUS_SOCKET, DaemonStatus, query_status}; use crate::tray::LoadedIcon; -/// Spawn the multi-server tray dashboard. +/// Run the tray dashboard on the current thread (required for macOS main thread). /// /// This creates a system tray icon that shows status of all managed servers. /// It queries the daemon status socket periodically and updates the menu. +/// Must be called from the main thread on macOS. +pub fn run_tray_dashboard( + shutdown: CancellationToken, + icon: Option, + status_socket: Option, +) { + let socket = status_socket.unwrap_or_else(|| PathBuf::from(DEFAULT_STATUS_SOCKET)); + + // Create a tokio runtime for async status queries + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + + rt.block_on(async move { + tray_dashboard_loop(socket, shutdown, icon).await; + }); +} + +/// Spawn the multi-server tray dashboard in a background thread. +/// +/// Note: On macOS, tray menus must be created on the main thread. +/// Use `run_tray_dashboard` instead for standalone dashboard commands. pub fn spawn_tray_dashboard( shutdown: CancellationToken, icon: Option, @@ -65,7 +88,7 @@ impl DashboardUi { fn update(&mut self, status: &DaemonStatus) { // Update header self.header - .set_text(format!("rmcp-mux v{} | {}", status.version, status.uptime)); + .set_text(format!("rust-mux v{} | {}", status.version, status.uptime)); // Update summary self.summary.set_text(format!( @@ -78,13 +101,19 @@ impl DashboardUi { if let Some(items) = self.servers.get(i) { let icon = status_icon(server.level); items.submenu.set_text(format!("{} {}", icon, server.name)); - items.status.set_text(format!(" Status: {}", server.status_text)); + items + .status + .set_text(format!(" Status: {}", server.status_text)); items.clients.set_text(format!( " Clients: {}/{}", server.active_clients, server.max_active_clients )); - items.pending.set_text(format!(" Pending: {}", server.pending_requests)); - items.restarts.set_text(format!(" Restarts: {}", server.restarts)); + items + .pending + .set_text(format!(" Pending: {}", server.pending_requests)); + items + .restarts + .set_text(format!(" Restarts: {}", server.restarts)); items.heartbeat.set_text(format!( " Heartbeat: {}", server @@ -99,9 +128,9 @@ impl DashboardUi { fn status_icon(level: StatusLevel) -> &'static str { match level { - StatusLevel::Ok => "βœ“", + StatusLevel::Ok => "●", StatusLevel::Warn => "⚠", - StatusLevel::Error => "βœ—", + StatusLevel::Error => "βœ–", StatusLevel::Lazy => "β—‹", } } @@ -164,7 +193,7 @@ fn build_dashboard(status: &DaemonStatus, icon_data: Option<&LoadedIcon>) -> Res // Header let header = MenuItem::new( - format!("rmcp-mux v{} | {}", status.version, status.uptime), + format!("rust-mux v{} | {}", status.version, status.uptime), false, None, ); @@ -195,11 +224,18 @@ fn build_dashboard(status: &DaemonStatus, icon_data: Option<&LoadedIcon>) -> Res let status_item = MenuItem::new(format!(" Status: {}", server.status_text), false, None); let clients_item = MenuItem::new( - format!(" Clients: {}/{}", server.active_clients, server.max_active_clients), + format!( + " Clients: {}/{}", + server.active_clients, server.max_active_clients + ), + false, + None, + ); + let pending_item = MenuItem::new( + format!(" Pending: {}", server.pending_requests), false, None, ); - let pending_item = MenuItem::new(format!(" Pending: {}", server.pending_requests), false, None); let restarts_item = MenuItem::new(format!(" Restarts: {}", server.restarts), false, None); let heartbeat_item = MenuItem::new( format!( @@ -247,7 +283,7 @@ fn build_dashboard(status: &DaemonStatus, icon_data: Option<&LoadedIcon>) -> Res }; let tray = TrayIconBuilder::new() - .with_tooltip("rmcp-mux Dashboard") + .with_tooltip("rust-mux Dashboard") .with_icon(icon) .with_menu(Box::new(menu)) .build()?; @@ -281,9 +317,9 @@ mod tests { #[test] fn status_icon_mapping() { - assert_eq!(status_icon(StatusLevel::Ok), "βœ“"); + assert_eq!(status_icon(StatusLevel::Ok), "●"); assert_eq!(status_icon(StatusLevel::Warn), "⚠"); - assert_eq!(status_icon(StatusLevel::Error), "βœ—"); + assert_eq!(status_icon(StatusLevel::Error), "βœ–"); assert_eq!(status_icon(StatusLevel::Lazy), "β—‹"); } diff --git a/src/wizard/clients.rs b/src/wizard/clients.rs deleted file mode 100644 index 680f95d..0000000 --- a/src/wizard/clients.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Client (host application) detection logic. - -use std::path::PathBuf; - -use crate::config::expand_path; -use crate::scan::{HostFormat, HostKind, discover_hosts, scan_host_file}; - -use super::types::ClientEntry; - -/// Known client application paths for detection -/// Returns: (HostKind, HostFormat, config_path, app_indicator_paths) -fn get_known_clients() -> Vec<(HostKind, HostFormat, PathBuf, Vec)> { - vec![ - // Codex - ( - HostKind::Codex, - HostFormat::Toml, - expand_path("~/.codex/config.toml"), - vec![expand_path("~/.codex")], // Codex dir exists if installed - ), - // Cursor (macOS) - ( - HostKind::Cursor, - HostFormat::Json, - expand_path("~/Library/Application Support/Cursor/User/settings.json"), - vec![ - expand_path("~/Library/Application Support/Cursor"), - expand_path("/Applications/Cursor.app"), - ], - ), - // Cursor (Linux) - ( - HostKind::Cursor, - HostFormat::Json, - expand_path("~/.config/Cursor/User/settings.json"), - vec![expand_path("~/.config/Cursor")], - ), - // VSCode (macOS) - ( - HostKind::VSCode, - HostFormat::Json, - expand_path("~/Library/Application Support/Code/User/settings.json"), - vec![ - expand_path("~/Library/Application Support/Code"), - expand_path("/Applications/Visual Studio Code.app"), - ], - ), - // VSCode (Linux) - ( - HostKind::VSCode, - HostFormat::Json, - expand_path("~/.config/Code/User/settings.json"), - vec![expand_path("~/.config/Code")], - ), - // Claude - ( - HostKind::Claude, - HostFormat::Json, - expand_path("~/.config/Claude/claude_config.json"), - vec![ - expand_path("~/.config/Claude"), - expand_path("/Applications/Claude.app"), - ], - ), - // JetBrains - ( - HostKind::JetBrains, - HostFormat::Json, - expand_path("~/Library/Application Support/JetBrains/LLM/mcp.json"), - vec![expand_path("~/Library/Application Support/JetBrains")], - ), - ] -} - -/// Check if any of the indicator paths exist (app is installed) -fn is_app_installed(indicator_paths: &[PathBuf]) -> bool { - indicator_paths.iter().any(|p| p.exists()) -} - -/// Detect MCP clients (host applications) -/// Detects both clients with existing config AND installed apps without config -pub fn detect_clients() -> Vec { - // First, get clients with existing configs (original behavior) - let hosts_with_config = discover_hosts(); - let mut clients = Vec::new(); - let mut seen_kinds: std::collections::HashSet<(HostKind, PathBuf)> = - std::collections::HashSet::new(); - - // Add clients with existing config files - for host in hosts_with_config { - seen_kinds.insert((host.kind, host.path.clone())); - - let scan_result = scan_host_file(&host).ok(); - - let services: Vec = scan_result - .as_ref() - .map(|r| r.services.iter().map(|s| s.name.clone()).collect()) - .unwrap_or_default(); - - let already_rewired = scan_result - .as_ref() - .map(|r| { - r.services - .iter() - .any(|s| s.command.contains("rmcp_mux") || s.command.contains("rmcp_mux_proxy")) - }) - .unwrap_or(false); - - clients.push(ClientEntry { - kind: host.kind, - config_path: host.path, - selected: !already_rewired, - services, - already_rewired, - config_exists: true, - }); - } - - // Now check for installed apps without config files - for (kind, _format, config_path, indicator_paths) in get_known_clients() { - // Skip if we already found this config - if seen_kinds.contains(&(kind, config_path.clone())) { - continue; - } - - // Check if app is installed but config doesn't exist - if !config_path.exists() && is_app_installed(&indicator_paths) { - clients.push(ClientEntry { - kind, - config_path, - selected: true, // Auto-select new clients - services: Vec::new(), - already_rewired: false, - config_exists: false, - }); - } - } - - clients -} diff --git a/src/wizard/keys.rs b/src/wizard/keys.rs index 23113df..cb44c9d 100644 --- a/src/wizard/keys.rs +++ b/src/wizard/keys.rs @@ -1,522 +1,849 @@ -//! Key handling logic for the wizard TUI. +//! Key handling for the 5-step wizard flow. + +use std::path::PathBuf; use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; -use super::clients::detect_clients; -use super::persist::{execute_confirm_choice, persist_all}; -use super::services::{check_health, default_server_config, form_from_service, service_from_form}; +use super::services::{ + append_default_discovered_services, build_services_from_scans, check_health, + enrich_running_state, +}; use super::types::{ - AppState, ConfirmChoice, Field, HealthCheckChoice, HealthStatus, Panel, ServiceEntry, - ServiceSource, WizardStep, next_confirm_choice, next_field, previous_confirm_choice, - previous_field, + AppState, PendingAction, SourceEntry, SourceStatus, Strategy, SummaryAction, TrayChoice, + WizardStep, }; +/// Top-level key dispatcher. Returns `Ok(true)` to break the TUI loop. +pub fn handle_key(app: &mut AppState, key: KeyEvent) -> Result { + match app.wizard_step { + WizardStep::DiscoverySources => handle_step1(app, key), + WizardStep::ServerReview => handle_step2(app, key), + WizardStep::StrategyChoice => handle_step3(app, key), + WizardStep::SummaryConfirm => handle_step4(app, key), + WizardStep::ResultAndTray => handle_step5(app, key), + } +} + // ───────────────────────────────────────────────────────────────────────────── -// Main key handler +// STEP 1: Discovery sources // ───────────────────────────────────────────────────────────────────────────── -pub fn handle_key(app: &mut AppState, key: KeyEvent) -> Result { - // Handle confirm dialog separately - if app.active_panel == Panel::ConfirmDialog { - return handle_confirm_dialog_key(app, key); +fn handle_step1(app: &mut AppState, key: KeyEvent) -> Result { + // While editing the custom path, every keystroke goes into the buffer. + if app.custom_path.editing { + match key.code { + KeyCode::Esc => { + app.custom_path.editing = false; + update_step1_message(app); + } + KeyCode::Enter => { + let path = std::mem::take(&mut app.custom_path.buffer); + app.custom_path.editing = false; + if path.trim().is_empty() { + app.custom_path.status = Some("Path was empty.".into()); + } else { + add_custom_source(app, &path); + } + update_step1_message(app); + } + KeyCode::Backspace => { + app.custom_path.buffer.pop(); + } + KeyCode::Char(c) => { + app.custom_path.buffer.push(c); + } + _ => {} + } + return Ok(false); } - let is_ctrl_s = key.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S')); - let is_plain_s = matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S')) - && app.editing.is_none() - && app.active_panel != Panel::Editor; - let is_save = is_ctrl_s || is_plain_s; - match key.code { - // Quit (only when not editing) - KeyCode::Char('q') if app.editing.is_none() => { - return Ok(true); + KeyCode::Char('q') => return Ok(true), + KeyCode::Char('i') => { + app.custom_path.editing = true; + app.message = "Editing custom path… type a path, Enter to add, Esc to cancel.".into(); } - - // Save -> open confirm dialog - _ if is_save => { - // First sync form to service - sync_form_to_service(app); - app.active_panel = Panel::ConfirmDialog; - app.confirm_choice = ConfirmChoice::SaveAll; - app.message = "Use arrows to select, Enter to confirm".into(); + KeyCode::Up if app.selected_source > 0 => { + app.selected_source -= 1; } - - // Tab to switch panels - KeyCode::Tab => { - if app.editing.is_none() { - app.active_panel = match app.active_panel { - Panel::ServiceList => Panel::Editor, - Panel::Editor => Panel::ServiceList, - Panel::ConfirmDialog => Panel::ConfirmDialog, - }; - update_message(app); - } + KeyCode::Down if app.selected_source + 1 < app.sources.len() => { + app.selected_source += 1; } - - KeyCode::BackTab => { - if app.editing.is_none() { - app.active_panel = match app.active_panel { - Panel::ServiceList => Panel::Editor, - Panel::Editor => Panel::ServiceList, - Panel::ConfirmDialog => Panel::ConfirmDialog, - }; - update_message(app); - } + KeyCode::Char(' ') if !app.sources.is_empty() => { + let idx = app.selected_source.min(app.sources.len() - 1); + app.sources[idx].selected = !app.sources[idx].selected; + update_step1_message(app); } - - // Navigation - KeyCode::Up => { - if app.editing.is_none() { - match (app.wizard_step, app.active_panel) { - (WizardStep::ServerSelection, Panel::ServiceList) => { - if app.selected_service > 0 { - sync_form_to_service(app); - app.selected_service -= 1; - load_service_to_form(app); - } - } - (WizardStep::ServerSelection, Panel::Editor) => { - app.current_field = previous_field(app.current_field); - } - (WizardStep::ClientSelection, Panel::ServiceList) => { - if app.selected_client > 0 { - app.selected_client -= 1; - } - } - (WizardStep::Confirmation, _) => { - // Navigate through save options - app.confirm_choice = previous_confirm_choice(app.confirm_choice); - } - (WizardStep::HealthCheck, _) => { - // Toggle between Ok and TryAgain - app.health_choice = match app.health_choice { - HealthCheckChoice::Ok => HealthCheckChoice::TryAgain, - HealthCheckChoice::TryAgain => HealthCheckChoice::Ok, - }; - } - _ => {} - } - } + KeyCode::Char('n') | KeyCode::Enter | KeyCode::Right => { + advance_to_step2(app); } + _ => {} + } + Ok(false) +} - KeyCode::Down => { - if app.editing.is_none() { - match (app.wizard_step, app.active_panel) { - (WizardStep::ServerSelection, Panel::ServiceList) => { - if app.selected_service < app.services.len().saturating_sub(1) { - sync_form_to_service(app); - app.selected_service += 1; - load_service_to_form(app); - } - } - (WizardStep::ServerSelection, Panel::Editor) => { - app.current_field = next_field(app.current_field); - } - (WizardStep::ClientSelection, Panel::ServiceList) => { - if app.selected_client < app.clients.len().saturating_sub(1) { - app.selected_client += 1; - } - } - (WizardStep::Confirmation, _) => { - // Navigate through save options - app.confirm_choice = next_confirm_choice(app.confirm_choice); - } - (WizardStep::HealthCheck, _) => { - // Toggle between Ok and TryAgain - app.health_choice = match app.health_choice { - HealthCheckChoice::Ok => HealthCheckChoice::TryAgain, - HealthCheckChoice::TryAgain => HealthCheckChoice::Ok, - }; - } - _ => {} - } - } +fn add_custom_source(app: &mut AppState, raw_path: &str) { + let expanded = crate::config::expand_path(raw_path); + let path = PathBuf::from(&expanded); + let host_file = crate::scan::host_file_from_custom_path(&path); + let status = if !path.exists() { + SourceStatus::Missing + } else { + match crate::scan::scan_host_file(&host_file) { + Ok(scan) if scan.services.is_empty() => SourceStatus::Empty, + Ok(scan) => SourceStatus::Ok { + servers_found: scan.services.len(), + }, + Err(err) => SourceStatus::InvalidFormat { + details: err.to_string(), + }, } + }; + app.custom_path.status = Some(format!( + "Added {}: {}", + host_file.path.display(), + status.short_label() + )); + app.sources.push(SourceEntry { + host_file, + status, + selected: true, + }); + app.selected_source = app.sources.len() - 1; +} - // Enter - KeyCode::Enter => { - match (app.wizard_step, app.active_panel) { - (WizardStep::ServerSelection, Panel::ServiceList) => { - // Switch to editor panel - app.active_panel = Panel::Editor; - update_message(app); - } - (WizardStep::ServerSelection, Panel::Editor) => { - if app.current_field == Field::Tray { - app.form.tray = !app.form.tray; - app.form.dirty = true; - } else { - app.editing = Some(app.current_field); - app.message = "Editing... Esc to finish".into(); - } - } - (WizardStep::ClientSelection, Panel::ServiceList) => { - // Toggle client selection on Enter as well - if app.selected_client < app.clients.len() { - app.clients[app.selected_client].selected = - !app.clients[app.selected_client].selected; - update_step2_message(app); - } - } - (WizardStep::Confirmation, _) => { - // Execute the selected action - return execute_confirm_choice(app); - } - (WizardStep::HealthCheck, _) => { - // Execute health check choice - return execute_health_check_choice(app); - } - _ => {} - } - } +fn update_step1_message(app: &mut AppState) { + if app.custom_path.editing { + app.message = "Editing custom path… type a path, Enter to add, Esc to cancel.".into(); + return; + } + let selected = app.sources.iter().filter(|s| s.selected).count(); + let total = app.sources.len(); + app.message = format!( + "STEP 1: {selected}/{total} sources selected | Space toggle | i custom path | n next | q quit" + ); +} - // Space toggles selection (in ServiceList for both steps) or tray (in Editor) - KeyCode::Char(' ') => { - match (app.wizard_step, app.active_panel) { - (WizardStep::ServerSelection, Panel::ServiceList) => { - // Toggle selection for current server - if app.selected_service < app.services.len() { - app.services[app.selected_service].selected = - !app.services[app.selected_service].selected; - let selected_count = app.services.iter().filter(|s| s.selected).count(); - app.message = format!( - "STEP 1: {} servers selected | Space: toggle | Tab: edit | n: next step", - selected_count - ); - } - } - (WizardStep::ServerSelection, Panel::Editor) => { - if app.current_field == Field::Tray { - app.form.tray = !app.form.tray; - app.form.dirty = true; - } - } - (WizardStep::ClientSelection, Panel::ServiceList) => { - // Toggle selection for current client - if app.selected_client < app.clients.len() { - app.clients[app.selected_client].selected = - !app.clients[app.selected_client].selected; - update_step2_message(app); - } - } - _ => {} - } - } +fn advance_to_step2(app: &mut AppState) { + let scans: Vec<_> = app + .sources + .iter() + .filter(|s| s.selected && matches!(s.status, SourceStatus::Ok { .. })) + .filter_map(|s| crate::scan::scan_host_file(&s.host_file).ok()) + .collect(); + + let mut services = build_services_from_scans(&scans); + append_default_discovered_services(&mut services); + enrich_running_state(&mut services); + + // Cheap health checks on entries with sockets, so STEP 2 has badge data + // available if a future view turns the column on. + for svc in &mut services { + svc.health = check_health(&svc.config); + } - // Escape - KeyCode::Esc => { - if app.editing.is_some() { - app.editing = None; - update_message(app); - } - } + app.services = services; + app.selected_service = 0; + app.wizard_step = WizardStep::ServerReview; + update_step2_message(app); +} - // Next step with 'n' key - KeyCode::Char('n') if app.editing.is_none() => { - match app.wizard_step { - WizardStep::ServerSelection => { - // Check if any servers are selected - let selected_count = app.services.iter().filter(|s| s.selected).count(); - if selected_count == 0 { - app.message = - "Please select at least one server (use Space to toggle)".into(); - } else { - // Move to step 2: Client Selection - sync_form_to_service(app); - app.wizard_step = WizardStep::ClientSelection; - app.clients = detect_clients(); - app.selected_client = 0; - app.active_panel = Panel::ServiceList; - let client_count = app.clients.len(); - app.message = format!( - "STEP 2: Client Detection - {} clients found | Space: toggle | n: next step | p: previous", - client_count - ); - } - } - WizardStep::ClientSelection => { - // Move to step 3: Confirmation - app.wizard_step = WizardStep::Confirmation; - app.active_panel = Panel::ConfirmDialog; - app.confirm_choice = ConfirmChoice::SaveAll; - app.message = "STEP 3: Confirm - Select action and press Enter".into(); - } - WizardStep::Confirmation => { - // Already at confirmation, do nothing - } - WizardStep::HealthCheck => { - // Already at last step, do nothing - } - } - } +// ───────────────────────────────────────────────────────────────────────────── +// STEP 2: Server review +// ───────────────────────────────────────────────────────────────────────────── - // Previous step with 'p' key - KeyCode::Char('p') if app.editing.is_none() => { - match app.wizard_step { - WizardStep::ServerSelection => { - // Already at first step, do nothing - } - WizardStep::ClientSelection => { - // Go back to step 1 - app.wizard_step = WizardStep::ServerSelection; - app.active_panel = Panel::ServiceList; - let selected_count = app.services.iter().filter(|s| s.selected).count(); - app.message = format!( - "STEP 1: Server Detection - {} servers selected | Space: toggle | n: next step", - selected_count - ); - } - WizardStep::Confirmation => { - // Go back to step 2 - app.wizard_step = WizardStep::ClientSelection; - app.active_panel = Panel::ServiceList; - let client_count = app.clients.len(); - app.message = format!( - "STEP 2: Client Detection - {} clients found | Space: toggle | n: next step | p: previous", - client_count - ); - } - WizardStep::HealthCheck => { - // Go back to step 3 - app.wizard_step = WizardStep::Confirmation; - app.active_panel = Panel::ConfirmDialog; - app.confirm_choice = ConfirmChoice::SaveAll; - app.message = "STEP 3: Confirm - Select action and press Enter".into(); - } - } +fn handle_step2(app: &mut AppState, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up if app.selected_service > 0 => { + app.selected_service -= 1; } - - // Add new service with 'a' key - KeyCode::Char('a') - if app.editing.is_none() - && app.active_panel == Panel::ServiceList - && app.wizard_step == WizardStep::ServerSelection => - { - let new_name = format!("new-service-{}", app.services.len() + 1); - app.services.push(ServiceEntry { - name: new_name, - config: default_server_config(), - health: HealthStatus::Unknown, - dirty: true, - source: ServiceSource::Config, - pid: None, - selected: true, - }); - app.selected_service = app.services.len() - 1; - load_service_to_form(app); - app.message = "New service added. Edit in the right panel.".into(); + KeyCode::Down if app.selected_service + 1 < app.services.len() => { + app.selected_service += 1; } - - // Refresh health with 'r' key (must be before general Char(c) handler) - KeyCode::Char('r') if app.editing.is_none() => { - for svc in &mut app.services { - svc.health = check_health(&svc.config); + KeyCode::Char(' ') if !app.services.is_empty() => { + let idx = app.selected_service.min(app.services.len() - 1); + app.services[idx].selected = !app.services[idx].selected; + update_step2_message(app); + } + KeyCode::Char('n') | KeyCode::Right | KeyCode::Enter => { + if app.services.iter().filter(|s| s.selected).count() == 0 { + app.message = "Select at least one server (Space) before continuing.".into(); + } else { + app.wizard_step = WizardStep::StrategyChoice; + update_step3_message(app); } - app.message = "Health checks refreshed".into(); } + KeyCode::Char('p') | KeyCode::Left => { + app.wizard_step = WizardStep::DiscoverySources; + update_step1_message(app); + } + _ => {} + } + Ok(false) +} - // Backspace in edit mode - KeyCode::Backspace => { - if let Some(field) = app.editing { - mutate_field(&mut app.form, field, |s| { - s.pop(); - }); +fn update_step2_message(app: &mut AppState) { + let selected = app.services.iter().filter(|s| s.selected).count(); + app.message = format!( + "STEP 2: {selected}/{} servers selected | Space toggle | n next | p prev | q quit", + app.services.len() + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// STEP 3: Strategy +// ───────────────────────────────────────────────────────────────────────────── + +fn handle_step3(app: &mut AppState, key: KeyEvent) -> Result { + let order = [Strategy::Unified, Strategy::PerClient, Strategy::AutoRewire]; + let idx = order.iter().position(|s| *s == app.strategy).unwrap_or(0); + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if idx > 0 { + app.strategy = order[idx - 1]; } } - - // Character input in edit mode (must be last among Char handlers) - KeyCode::Char(c) => { - if let Some(field) = app.editing { - mutate_field(&mut app.form, field, |s| s.push(c)); + KeyCode::Down => { + if idx + 1 < order.len() { + app.strategy = order[idx + 1]; } } - + KeyCode::Char('1') => app.strategy = Strategy::Unified, + KeyCode::Char('2') => app.strategy = Strategy::PerClient, + KeyCode::Char('3') => app.strategy = Strategy::AutoRewire, + KeyCode::Char('n') | KeyCode::Right | KeyCode::Enter => { + app.wizard_step = WizardStep::SummaryConfirm; + app.summary_action = SummaryAction::Confirm; + update_step4_message(app); + } + KeyCode::Char('p') | KeyCode::Left => { + app.wizard_step = WizardStep::ServerReview; + update_step2_message(app); + } _ => {} } - Ok(false) } +fn update_step3_message(app: &mut AppState) { + app.message = "STEP 3: Up/Down to choose strategy | 1/2/3 quick pick | n next | p prev".into(); + let _ = app; +} + // ───────────────────────────────────────────────────────────────────────────── -// Confirm dialog key handler +// STEP 4: Summary + confirm // ───────────────────────────────────────────────────────────────────────────── -fn handle_confirm_dialog_key(app: &mut AppState, key: KeyEvent) -> Result { - // Order of choices for navigation: SaveAll, SaveMuxOnly, CopyToClipboard, Back, Exit - let choices = [ - ConfirmChoice::SaveAll, - ConfirmChoice::SaveMuxOnly, - ConfirmChoice::CopyToClipboard, - ConfirmChoice::Back, - ConfirmChoice::Exit, +fn handle_step4(app: &mut AppState, key: KeyEvent) -> Result { + let order = [ + SummaryAction::Confirm, + SummaryAction::Back, + SummaryAction::Cancel, ]; - let current_idx = choices + let idx = order .iter() - .position(|c| *c == app.confirm_choice) + .position(|a| *a == app.summary_action) .unwrap_or(0); - match key.code { - KeyCode::Left => { - let new_idx = if current_idx == 0 { - choices.len() - 1 - } else { - current_idx - 1 - }; - app.confirm_choice = choices[new_idx]; + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if idx > 0 { + app.summary_action = order[idx - 1]; + } } - KeyCode::Right => { - let new_idx = (current_idx + 1) % choices.len(); - app.confirm_choice = choices[new_idx]; + KeyCode::Down => { + if idx + 1 < order.len() { + app.summary_action = order[idx + 1]; + } } - KeyCode::Enter => match app.confirm_choice { - ConfirmChoice::SaveAll => { - if !app.dry_run { - persist_all(app)?; - // TODO: Also rewire selected clients - } - app.message = if app.dry_run { - "Dry run: config would be saved. Exiting...".into() - } else { - "Configuration saved!".into() - }; + KeyCode::Char('p') | KeyCode::Left => { + app.wizard_step = WizardStep::StrategyChoice; + update_step3_message(app); + } + KeyCode::Enter => match app.summary_action { + SummaryAction::Confirm => { + queue_pending_for_strategy(app); return Ok(true); } - ConfirmChoice::SaveMuxOnly => { - if !app.dry_run { - persist_all(app)?; - } - app.message = if app.dry_run { - "Dry run: mux config would be saved. Exiting...".into() - } else { - "Mux configuration saved!".into() - }; - return Ok(true); + SummaryAction::Back => { + app.wizard_step = WizardStep::StrategyChoice; + update_step3_message(app); } - ConfirmChoice::CopyToClipboard => { - // TODO: Copy config to clipboard - app.message = "Config copied to clipboard (not yet implemented)".into(); + SummaryAction::Cancel => return Ok(true), + }, + _ => {} + } + Ok(false) +} + +fn update_step4_message(app: &mut AppState) { + app.message = "STEP 4: Up/Down to pick action | Enter to do it | p back | q quit".into(); + let _ = app; +} + +fn queue_pending_for_strategy(app: &mut AppState) { + app.pending_action = Some(match app.strategy { + Strategy::Unified => PendingAction::GenerateUnified, + Strategy::PerClient => PendingAction::GeneratePerClient, + Strategy::AutoRewire => PendingAction::AutoRewire, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// STEP 5: Result + tray prompt +// ───────────────────────────────────────────────────────────────────────────── + +fn handle_step5(app: &mut AppState, key: KeyEvent) -> Result { + let order = [TrayChoice::StartNow, TrayChoice::No]; + let idx = order + .iter() + .position(|t| *t == app.tray_choice) + .unwrap_or(0); + match key.code { + KeyCode::Char('q') => return Ok(true), + KeyCode::Up => { + if idx > 0 { + app.tray_choice = order[idx - 1]; } - ConfirmChoice::Back => { - app.active_panel = Panel::Editor; - update_message(app); + } + KeyCode::Down => { + if idx + 1 < order.len() { + app.tray_choice = order[idx + 1]; } - ConfirmChoice::Exit => { + } + KeyCode::Enter => match app.tray_choice { + TrayChoice::StartNow => { + app.pending_action = Some(PendingAction::StartTrayDaemon); return Ok(true); } + TrayChoice::No => return Ok(true), }, - KeyCode::Esc => { - app.active_panel = Panel::Editor; - update_message(app); - } _ => {} } Ok(false) } // ───────────────────────────────────────────────────────────────────────────── -// Health check choice handler +// Tests // ───────────────────────────────────────────────────────────────────────────── -fn execute_health_check_choice(app: &mut AppState) -> Result { - match app.health_choice { - HealthCheckChoice::Ok => { - // Configuration verified, exit wizard - app.message = "Configuration verified successfully! Exiting...".into(); - Ok(true) +#[cfg(test)] +mod tests { + use super::super::types::{ + CustomPathInput, HealthStatus, ServiceEntry, ServiceSource, SourceEntry, + }; + use super::*; + use crate::config::ServerConfig; + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat, HostKind}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + // ───── Fixtures ───── + + /// Minimal `ServerConfig` for synthesising STEP 2 entries. + fn dummy_server_config(name: &str) -> ServerConfig { + ServerConfig { + socket: None, + cmd: Some("npx".into()), + args: Some(vec![format!("@modelcontextprotocol/server-{name}")]), + cwd: None, + env: None, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(name.into()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), } - HealthCheckChoice::TryAgain => { - // Re-run detection and go back to step 1 - use super::services::load_all_services; + } - // Reload services from config and detect running processes - if let Ok(services) = load_all_services(&app.config_path) { - app.services = services; - } + /// Build an AppState wired for a given step. Skips `run_wizard` entirely + /// (which requires a TTY); produces just enough state for dispatcher + /// tests. + fn make_app(step: WizardStep) -> AppState { + AppState { + wizard_step: step, + config_path: PathBuf::from("/tmp/rust-mux-test-config.toml"), + sources: Vec::new(), + selected_source: 0, + custom_path: CustomPathInput::default(), + services: Vec::new(), + selected_service: 0, + strategy: Strategy::Unified, + summary_action: SummaryAction::Confirm, + tray_choice: TrayChoice::No, + message: String::new(), + dry_run: true, + pending_action: None, + strategy_result: None, + } + } - // Run health checks on all services - for svc in &mut app.services { - svc.health = check_health(&svc.config); - } + /// Synthesise a `SourceEntry` whose `host_file.path` is a real on-disk + /// JSON file so `advance_to_step2` can re-scan it through + /// `scan_host_file`. + fn ok_source_at(path: PathBuf, servers_found: usize) -> SourceEntry { + SourceEntry { + host_file: HostFile { + kind: HostKind::Claude, + path, + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + status: SourceStatus::Ok { servers_found }, + selected: true, + } + } + + /// Build an AppState on STEP 1 with one selected source backed by a + /// real tempdir-hosted JSON config containing one MCP server. + fn make_app_with_sources() -> (AppState, tempfile::TempDir) { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("claude.json"); + fs::write( + &path, + r#"{"mcpServers": {"memory": {"command": "npx", "args": ["@modelcontextprotocol/server-memory"]}}}"#, + ) + .expect("write fixture"); + + let mut app = make_app(WizardStep::DiscoverySources); + app.sources.push(ok_source_at(path, 1)); + (app, dir) + } - // Clear clients (will be re-detected in step 2) - app.clients.clear(); - app.selected_client = 0; + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } - // Reset to step 1 - app.wizard_step = WizardStep::ServerSelection; - app.selected_service = 0; - app.active_panel = Panel::ServiceList; - app.health_choice = HealthCheckChoice::Ok; + fn make_service(name: &str, selected: bool) -> ServiceEntry { + ServiceEntry { + name: name.into(), + config: dummy_server_config(name), + health: HealthStatus::Unknown, + source: ServiceSource::Client { + kind: HostKind::Claude, + path: PathBuf::from("/tmp/test-claude.json"), + }, + pid: None, + selected, + } + } - if !app.services.is_empty() { - load_service_to_form(app); + // ───── STEP 1 β†’ STEP 2 (happy path) ───── + + #[test] + fn step1_n_advances_to_step2_when_a_source_is_selected() { + let (mut app, _dir) = make_app_with_sources(); + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done, "n should not exit the loop"); + assert_eq!(app.wizard_step, WizardStep::ServerReview); + // `advance_to_step2` runs `enrich_running_state`, which scans the + // host's `ps` output for live MCP processes. The fixture contributes + // one entry ("memory") tagged `Client { kind: Claude, .. }`; any + // additional entries observed at test time are orphan + // `DetectedRunning` workers from the host. Assert on the fixture + // signal, not the total count, so the test stays stable across + // machines. + let memory = app + .services + .iter() + .find(|s| s.name == "memory") + .expect("memory service must be derived from the fixture"); + assert!(memory.selected); + assert!(matches!( + memory.source, + ServiceSource::Client { + kind: HostKind::Claude, + .. } + )); + } + + // ───── STEP 2 β†’ STEP 3 (gated by selection count) ───── + + #[test] + fn step2_blocks_advance_when_zero_services_selected() { + let mut app = make_app(WizardStep::ServerReview); + app.services.push(make_service("memory", false)); + app.services.push(make_service("filesystem", false)); + + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done); + assert_eq!( + app.wizard_step, + WizardStep::ServerReview, + "must not advance with zero selected services" + ); + assert!(app.message.contains("Select at least one server")); + } + + #[test] + fn step2_advances_when_at_least_one_service_selected() { + let mut app = make_app(WizardStep::ServerReview); + app.services.push(make_service("memory", true)); + app.services.push(make_service("filesystem", false)); + + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done); + assert_eq!(app.wizard_step, WizardStep::StrategyChoice); + } + + // ───── STEP 3 β†’ STEP 4 (per strategy) ───── + + #[test] + fn step3_advances_to_step4_for_unified() { + let mut app = make_app(WizardStep::StrategyChoice); + app.strategy = Strategy::Unified; + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done); + assert_eq!(app.wizard_step, WizardStep::SummaryConfirm); + assert_eq!(app.summary_action, SummaryAction::Confirm); + } + + #[test] + fn step3_advances_to_step4_for_per_client() { + let mut app = make_app(WizardStep::StrategyChoice); + app.strategy = Strategy::PerClient; + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done); + assert_eq!(app.wizard_step, WizardStep::SummaryConfirm); + assert_eq!(app.summary_action, SummaryAction::Confirm); + } + + #[test] + fn step3_advances_to_step4_for_auto_rewire() { + let mut app = make_app(WizardStep::StrategyChoice); + app.strategy = Strategy::AutoRewire; + let done = handle_key(&mut app, key(KeyCode::Char('n'))).expect("dispatch"); + assert!(!done); + assert_eq!(app.wizard_step, WizardStep::SummaryConfirm); + assert_eq!(app.summary_action, SummaryAction::Confirm); + } + + #[test] + fn step3_quick_pick_keys_set_strategy() { + let mut app = make_app(WizardStep::StrategyChoice); + handle_key(&mut app, key(KeyCode::Char('1'))).expect("1"); + assert_eq!(app.strategy, Strategy::Unified); + handle_key(&mut app, key(KeyCode::Char('2'))).expect("2"); + assert_eq!(app.strategy, Strategy::PerClient); + handle_key(&mut app, key(KeyCode::Char('3'))).expect("3"); + assert_eq!(app.strategy, Strategy::AutoRewire); + } + + // ───── STEP 4 Confirm/Cancel/Back ───── + + #[test] + fn step4_confirm_unified_queues_generate_unified_and_exits_loop() { + let mut app = make_app(WizardStep::SummaryConfirm); + app.strategy = Strategy::Unified; + app.summary_action = SummaryAction::Confirm; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done, "Confirm must break the TUI loop"); + assert_eq!(app.pending_action, Some(PendingAction::GenerateUnified)); + } + + #[test] + fn step4_confirm_per_client_queues_generate_per_client() { + let mut app = make_app(WizardStep::SummaryConfirm); + app.strategy = Strategy::PerClient; + app.summary_action = SummaryAction::Confirm; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done); + assert_eq!(app.pending_action, Some(PendingAction::GeneratePerClient)); + } + + #[test] + fn step4_confirm_auto_rewire_queues_auto_rewire() { + let mut app = make_app(WizardStep::SummaryConfirm); + app.strategy = Strategy::AutoRewire; + app.summary_action = SummaryAction::Confirm; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done); + assert_eq!(app.pending_action, Some(PendingAction::AutoRewire)); + } - let selected_count = app.services.iter().filter(|s| s.selected).count(); - app.message = format!( - "STEP 1: Server Detection (retry) - {} servers | Space: toggle | n: next step", - selected_count - ); + #[test] + fn step4_cancel_exits_with_no_pending_action() { + let mut app = make_app(WizardStep::SummaryConfirm); + app.strategy = Strategy::Unified; + app.summary_action = SummaryAction::Cancel; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done, "Cancel must break the TUI loop"); + assert!( + app.pending_action.is_none(), + "Cancel must not queue any persistence action" + ); + } + + #[test] + fn step4_back_returns_to_strategy_choice_without_action() { + let mut app = make_app(WizardStep::SummaryConfirm); + app.strategy = Strategy::Unified; + app.summary_action = SummaryAction::Back; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(!done, "Back must keep the loop running"); + assert_eq!(app.wizard_step, WizardStep::StrategyChoice); + assert!(app.pending_action.is_none()); + } - Ok(false) + // ───── STEP 5 tray prompt ───── + + #[test] + fn step5_start_now_queues_start_tray_daemon() { + let mut app = make_app(WizardStep::ResultAndTray); + app.tray_choice = TrayChoice::StartNow; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done); + assert_eq!(app.pending_action, Some(PendingAction::StartTrayDaemon)); + } + + #[test] + fn step5_no_exits_with_no_pending_action() { + let mut app = make_app(WizardStep::ResultAndTray); + app.tray_choice = TrayChoice::No; + + let done = handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!(done); + assert!(app.pending_action.is_none()); + } + + // ───── STEP 1 custom-path edit mode ───── + + #[test] + fn step1_i_enters_custom_path_editing_mode() { + let mut app = make_app(WizardStep::DiscoverySources); + handle_key(&mut app, key(KeyCode::Char('i'))).expect("dispatch"); + assert!(app.custom_path.editing); + assert!(app.message.contains("Editing custom path")); + } + + #[test] + fn step1_editing_buffers_typed_chars_and_handles_backspace() { + let mut app = make_app(WizardStep::DiscoverySources); + app.custom_path.editing = true; + + for c in "/tmp/x".chars() { + handle_key(&mut app, key(KeyCode::Char(c))).expect("dispatch"); } + assert_eq!(app.custom_path.buffer, "/tmp/x"); + + handle_key(&mut app, key(KeyCode::Backspace)).expect("dispatch"); + assert_eq!(app.custom_path.buffer, "/tmp/"); + assert!( + app.custom_path.editing, + "Backspace must not exit editing mode" + ); } -} -// ───────────────────────────────────────────────────────────────────────────── -// Helper functions -// ───────────────────────────────────────────────────────────────────────────── + #[test] + fn step1_enter_on_empty_buffer_reports_path_was_empty() { + let mut app = make_app(WizardStep::DiscoverySources); + app.custom_path.editing = true; + app.custom_path.buffer.clear(); + + handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + assert!( + !app.custom_path.editing, + "Enter must close the edit field even on empty input" + ); + assert_eq!( + app.custom_path.status.as_deref(), + Some("Path was empty."), + "empty Enter must surface the explicit status string" + ); + assert!( + app.sources.is_empty(), + "empty Enter must not append a SourceEntry" + ); + } -pub fn sync_form_to_service(app: &mut AppState) { - if app.form.dirty { - let idx = app.selected_service; - if idx < app.services.len() { - app.services[idx].name = app.form.service_name.clone(); - app.services[idx].config = service_from_form(&app.form); - app.services[idx].dirty = true; + #[test] + fn step1_enter_on_non_empty_path_appends_source_entry() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("custom.json"); + fs::write( + &path, + r#"{"mcpServers": {"foo": {"command": "echo", "args": ["foo"]}}}"#, + ) + .expect("write fixture"); + + let mut app = make_app(WizardStep::DiscoverySources); + app.custom_path.editing = true; + app.custom_path.buffer = path.to_string_lossy().into_owned(); + + handle_key(&mut app, key(KeyCode::Enter)).expect("dispatch"); + + assert!(!app.custom_path.editing); + assert_eq!( + app.sources.len(), + 1, + "custom path must be added as a source" + ); + assert!(app.sources[0].selected); + match &app.sources[0].status { + SourceStatus::Ok { servers_found } => assert_eq!(*servers_found, 1), + other => panic!("expected SourceStatus::Ok with 1 server, got {other:?}"), } - app.form.dirty = false; } -} -pub fn load_service_to_form(app: &mut AppState) { - let idx = app.selected_service; - if idx < app.services.len() { - app.form = form_from_service(&app.services[idx]); + #[test] + fn step1_esc_in_edit_mode_cancels_editing() { + let mut app = make_app(WizardStep::DiscoverySources); + app.custom_path.editing = true; + app.custom_path.buffer = "/tmp/abc".into(); + + handle_key(&mut app, key(KeyCode::Esc)).expect("dispatch"); + assert!(!app.custom_path.editing, "Esc must exit edit mode"); + // Esc preserves the buffer (only Enter consumes it via mem::take). + assert_eq!(app.custom_path.buffer, "/tmp/abc"); } -} -pub fn update_message(app: &mut AppState) { - app.message = match app.active_panel { - Panel::ServiceList => "Tab: switch | Up/Down: select | Enter: edit | n: new | r: refresh health | s: save | q: quit".into(), - Panel::Editor => "Tab: switch | Up/Down: navigate | Enter: edit field | Space: toggle tray | Esc: stop edit | s: save".into(), - Panel::ConfirmDialog => "Left/Right: select | Enter: confirm | Esc: cancel".into(), - }; -} + // ───── STEP 1 Up/Down clamping ───── + + #[test] + fn step1_up_clamps_at_zero() { + let (mut app, _dir) = make_app_with_sources(); + // Add a second source so cursor can move at all. + let path = _dir.path().join("second.json"); + fs::write(&path, r#"{"mcpServers": {}}"#).expect("write"); + app.sources.push(SourceEntry { + host_file: HostFile { + kind: HostKind::Claude, + path, + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + status: SourceStatus::Empty, + selected: false, + }); + + app.selected_source = 0; + handle_key(&mut app, key(KeyCode::Up)).expect("dispatch"); + assert_eq!(app.selected_source, 0, "Up at index 0 must clamp"); + } -fn mutate_field(form: &mut super::types::FormState, field: Field, f: F) { - let target = match field { - Field::ServiceName => &mut form.service_name, - Field::Socket => &mut form.socket, - Field::Cmd => &mut form.cmd, - Field::Args => &mut form.args, - Field::Env => &mut form.env, - Field::MaxClients => &mut form.max_clients, - Field::LogLevel => &mut form.log_level, - Field::Tray => return, - }; - f(target); - form.dirty = true; -} + #[test] + fn step1_down_clamps_at_last_index() { + let (mut app, dir) = make_app_with_sources(); + let path = dir.path().join("second.json"); + fs::write(&path, r#"{"mcpServers": {}}"#).expect("write"); + app.sources.push(SourceEntry { + host_file: HostFile { + kind: HostKind::Claude, + path, + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + status: SourceStatus::Empty, + selected: false, + }); + + app.selected_source = app.sources.len() - 1; + handle_key(&mut app, key(KeyCode::Down)).expect("dispatch"); + assert_eq!( + app.selected_source, + app.sources.len() - 1, + "Down at last index must clamp" + ); + } -fn update_step2_message(app: &mut AppState) { - let selected_count = app.clients.iter().filter(|c| c.selected).count(); - let total_count = app.clients.len(); - app.message = format!( - "STEP 2: {} of {} clients selected | Space: toggle | n: next step | p: previous", - selected_count, total_count - ); + // ───── STEP 2 Up/Down clamping ───── + + #[test] + fn step2_up_down_clamps_at_boundaries() { + let mut app = make_app(WizardStep::ServerReview); + app.services.push(make_service("a", true)); + app.services.push(make_service("b", true)); + + app.selected_service = 0; + handle_key(&mut app, key(KeyCode::Up)).expect("dispatch"); + assert_eq!(app.selected_service, 0, "Up at 0 must clamp"); + + app.selected_service = app.services.len() - 1; + handle_key(&mut app, key(KeyCode::Down)).expect("dispatch"); + assert_eq!( + app.selected_service, + app.services.len() - 1, + "Down at last must clamp" + ); + } + + // ───── q quits on every step ───── + + #[test] + fn q_quits_from_any_step() { + for step in [ + WizardStep::DiscoverySources, + WizardStep::ServerReview, + WizardStep::StrategyChoice, + WizardStep::SummaryConfirm, + WizardStep::ResultAndTray, + ] { + let mut app = make_app(step); + let done = handle_key(&mut app, key(KeyCode::Char('q'))).expect("dispatch"); + assert!(done, "q must break the loop on {:?}", step); + } + } + + // ───── Render smoke tests (no .snap files; just confirm draw_ui never + // panics on each step's state with TestBackend). ───── + + #[test] + fn draw_ui_renders_without_panic_on_every_step() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::super::ui::draw_ui; + + let steps = [ + WizardStep::DiscoverySources, + WizardStep::ServerReview, + WizardStep::StrategyChoice, + WizardStep::SummaryConfirm, + WizardStep::ResultAndTray, + ]; + for step in steps { + let mut app = make_app(step); + // Populate enough state for non-empty rendering. + app.services.push(make_service("memory", true)); + app.strategy_result = Some("dry-run summary".into()); + app.message = format!("rendering {step:?}"); + + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| draw_ui(f, &app)) + .unwrap_or_else(|e| panic!("draw_ui panicked on {step:?}: {e}")); + } + } } diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index 2621ae0..140d35e 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -1,10 +1,15 @@ -//! Interactive wizard for configuring rmcp_mux services and rewiring MCP clients. +//! Interactive wizard for configuring rust-mux services and rewiring MCP clients. //! -//! The wizard provides a four-step TUI flow: -//! 1. Server Detection - detect and select MCP servers -//! 2. Client Detection - detect and select MCP clients (hosts) -//! 3. Confirmation - review and save configuration -//! 4. Health Check - verify configuration works, with option to retry +//! v0.4.0 5-step flow: +//! +//! 1. **DiscoverySources** β€” pick which client config files to scan, plus +//! optional custom paths. +//! 2. **ServerReview** β€” read-only tree of discovered servers grouped by +//! client, with dedup count. +//! 3. **StrategyChoice** β€” Unified / Per-client / Auto-rewire (DANGER). +//! 4. **SummaryConfirm** β€” preview of what will be written and where. +//! 5. **ResultAndTray** β€” show what happened, offer to start the tray +//! daemon now. use std::io::{IsTerminal, stdout}; use std::path::PathBuf; @@ -21,8 +26,8 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use crate::config::expand_path; +use crate::scan::{default_sources, scan_host_file}; -mod clients; mod keys; mod persist; mod services; @@ -30,10 +35,13 @@ mod types; mod ui; use keys::handle_key; -use services::{check_health, default_server_config, form_from_service, load_all_services}; +use persist::{ + run_danger_auto_configure, run_per_client_generate, run_unified_generate, start_tray_daemon, +}; +use services::{append_default_discovered_services, build_services_from_scans}; use types::{ - AppState, ConfirmChoice, Field, HealthCheckChoice, HealthStatus, Panel, ServiceEntry, - ServiceSource, WizardStep, + AppState, CustomPathInput, PendingAction, SourceEntry, SourceStatus, Strategy, SummaryAction, + TrayChoice, WizardStep, }; use ui::draw_ui; @@ -43,43 +51,59 @@ use ui::draw_ui; #[derive(Debug, Clone, Args)] pub struct WizardArgs { - /// Path to mux config (json/yaml/toml). Default: ~/.codex/mcp-mux.toml (expanded to home directory) + /// Path to mux daemon config (json/yaml/toml). Default: ~/.codex/mcp-mux.toml. + /// Used as the fallback target when starting the tray daemon if no + /// generated mux config is available. #[arg(long)] pub config: Option, - /// Service key to edit or create. + /// Service key (kept for backwards compatibility; ignored by the + /// 5-step flow which discovers from client configs). #[arg(long)] pub service: Option, - /// Socket path override. + /// Socket path override (legacy; ignored). #[arg(long)] pub socket: Option, - /// Command override (e.g. npx). + /// Command override (legacy; ignored). #[arg(long)] pub cmd: Option, - /// Args override (space separated). + /// Args override (legacy; ignored). #[arg(long)] pub args: Vec, - /// Max clients override. + /// Max clients override (legacy; ignored). #[arg(long)] pub max_clients: Option, - /// Log level override. + /// Log level override (legacy; ignored). #[arg(long)] pub log_level: Option, - /// Tray override. + /// Tray override (legacy; the wizard offers a tray prompt on STEP 5). #[arg(long)] pub tray: Option, /// Do not write files; just preview. #[arg(long, default_value_t = false)] pub dry_run: bool, + /// Non-interactive strategy preview. Currently supports `unified` with + /// `--dry-run`, so CI/agents can smoke the wizard discovery path without + /// driving ratatui. + #[arg(long)] + pub strategy: Option, + /// Pre-load extra MCP client config files. Each path is added as a + /// custom source on STEP 1 and selected by default. + #[arg(long = "import-config")] + pub import_configs: Vec, } // ───────────────────────────────────────────────────────────────────────────── -// Main entry point +// Entry point // ───────────────────────────────────────────────────────────────────────────── pub async fn run_wizard(args: WizardArgs) -> Result<()> { + if let Some(strategy) = args.strategy.as_deref() { + return run_wizard_strategy_preview(&args, strategy); + } + if !stdout().is_terminal() { return Err(anyhow!( - "wizard requires an interactive TTY; run with --config/--service in non-interactive mode" + "wizard requires an interactive TTY; use the CLI subcommands (scan / rewire / health) for non-interactive mode" )); } @@ -88,88 +112,137 @@ pub async fn run_wizard(args: WizardArgs) -> Result<()> { .clone() .unwrap_or_else(|| expand_path("~/.codex/mcp-mux.toml")); - let mut services = load_all_services(&config_path)?; - - // If --service provided, ensure it exists in the list - if let Some(ref svc_name) = args.service - && !services.iter().any(|s| s.name == *svc_name) - { - services.push(ServiceEntry { - name: svc_name.clone(), - config: default_server_config(), - health: HealthStatus::Unknown, - dirty: false, - source: ServiceSource::Config, - pid: None, - selected: true, - }); - } + let sources = build_initial_sources(&args.import_configs); - // If list is empty, add a default entry - if services.is_empty() { - services.push(ServiceEntry { - name: "general-memory".into(), - config: default_server_config(), - health: HealthStatus::Unknown, - dirty: false, - source: ServiceSource::Config, - pid: None, - selected: true, - }); - } + let mut app = AppState { + wizard_step: WizardStep::DiscoverySources, + config_path, + sources, + selected_source: 0, + custom_path: CustomPathInput::default(), + services: Vec::new(), + selected_service: 0, + strategy: Strategy::Unified, + summary_action: SummaryAction::Confirm, + tray_choice: TrayChoice::No, + message: String::new(), + dry_run: args.dry_run, + pending_action: None, + strategy_result: None, + }; + refresh_step1_message(&mut app); - // Run initial health checks - for svc in &mut services { - svc.health = check_health(&svc.config); - } + run_tui(&mut app)?; - // Find initial selection - let selected = if let Some(ref svc_name) = args.service { - services - .iter() - .position(|s| s.name == *svc_name) - .unwrap_or(0) - } else { - 0 - }; + Ok(()) +} - let form = form_from_service(&services[selected]); +fn run_wizard_strategy_preview(args: &WizardArgs, strategy: &str) -> Result<()> { + if !args.dry_run { + return Err(anyhow!( + "wizard --strategy currently supports preview only; add --dry-run" + )); + } + if strategy != "unified" { + return Err(anyhow!( + "unsupported wizard strategy '{strategy}' (currently supported: unified)" + )); + } - // Default socket directory - let socket_dir = expand_path("~/mcp-sockets"); + let config_path = args + .config + .clone() + .unwrap_or_else(|| expand_path("~/.codex/mcp-mux.toml")); + let sources = build_initial_sources(&args.import_configs); + let scans = sources + .iter() + .filter(|s| s.selected && matches!(s.status, SourceStatus::Ok { .. })) + .filter_map(|s| scan_host_file(&s.host_file).ok()) + .collect::>(); + let mut services = build_services_from_scans(&scans); + append_default_discovered_services(&mut services); - let mut app = AppState { - wizard_step: WizardStep::ServerSelection, + let app = AppState { + wizard_step: WizardStep::SummaryConfirm, config_path, - socket_dir, + sources, + selected_source: 0, + custom_path: CustomPathInput::default(), services, - selected_service: selected, - clients: Vec::new(), // Will be populated in step 2 - selected_client: 0, - form, - current_field: Field::ServiceName, - editing: None, - active_panel: Panel::ServiceList, - confirm_choice: ConfirmChoice::SaveAll, - health_choice: HealthCheckChoice::Ok, - message: "STEP 1: Server Detection - Space: toggle selection | Tab: switch | Enter: edit | n: next step | q: quit".into(), - dry_run: args.dry_run, + selected_service: 0, + strategy: Strategy::Unified, + summary_action: SummaryAction::Confirm, + tray_choice: TrayChoice::No, + message: String::new(), + dry_run: true, + pending_action: None, + strategy_result: None, }; - run_tui(&mut app)?; - + println!("{}", run_unified_generate(&app)?); Ok(()) } +/// Walk the canonical `default_sources()` list, classify each entry, then +/// append any `--import-config` paths the operator passed on the CLI. +fn build_initial_sources(imports: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for source in default_sources() { + let status = classify_source(&source); + let exists = matches!( + status, + SourceStatus::Ok { .. } | SourceStatus::Empty | SourceStatus::InvalidFormat { .. } + ); + out.push(SourceEntry { + host_file: source, + status, + selected: exists, + }); + } + for path in imports { + let host = crate::scan::host_file_from_custom_path(path); + let status = classify_source(&host); + out.push(SourceEntry { + host_file: host, + status, + selected: true, + }); + } + out +} + +fn classify_source(host: &crate::scan::HostFile) -> SourceStatus { + if !host.path.exists() { + return SourceStatus::Missing; + } + match scan_host_file(host) { + Ok(scan) if scan.services.is_empty() => SourceStatus::Empty, + Ok(scan) => SourceStatus::Ok { + servers_found: scan.services.len(), + }, + Err(err) => SourceStatus::InvalidFormat { + details: err.to_string(), + }, + } +} + +fn refresh_step1_message(app: &mut AppState) { + let selected = app.sources.iter().filter(|s| s.selected).count(); + let total = app.sources.len(); + app.message = format!( + "STEP 1: {selected}/{total} sources selected | Space toggle | i custom path | n next | q quit" + ); +} + // ───────────────────────────────────────────────────────────────────────────── -// TUI main loop +// TUI loop // ───────────────────────────────────────────────────────────────────────────── fn run_tui(app: &mut AppState) -> Result<()> { enable_raw_mode()?; - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); + let mut stdout_handle = stdout(); + execute!(stdout_handle, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout_handle); let mut terminal = Terminal::new(backend)?; terminal.hide_cursor()?; @@ -190,8 +263,94 @@ fn run_tui(app: &mut AppState) -> Result<()> { } } + // Drop the alternate screen so cooked stdout/stdin work for the + // post-loop drain below. + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + drain_pending_actions(app)?; + + Ok(()) +} + +/// Handles every action that needs cooked stdin/stdout (printing summaries, +/// the danger CONFIRM prompt, spawning the tray daemon). +/// +/// The flow is: +/// - Strategy actions print their result, set `strategy_result`, re-enter the +/// alt screen, switch to STEP 5, and run the loop again so the operator can +/// choose the tray prompt. +/// - The tray-daemon spawn is terminal: it prints the spawn line and exits. +fn drain_pending_actions(app: &mut AppState) -> Result<()> { + let Some(action) = app.pending_action.take() else { + return Ok(()); + }; + + match action { + PendingAction::GenerateUnified => { + let summary = run_unified_generate(app)?; + println!("\n{summary}"); + advance_to_step5(app, summary)?; + } + PendingAction::GeneratePerClient => { + let summary = run_per_client_generate(app)?; + println!("\n{summary}"); + advance_to_step5(app, summary)?; + } + PendingAction::AutoRewire => { + let summary = run_danger_auto_configure(app)?; + println!("\n{summary}"); + advance_to_step5(app, summary)?; + } + PendingAction::StartTrayDaemon => { + let summary = start_tray_daemon(app)?; + println!("\n{summary}\n"); + } + } + Ok(()) +} + +/// Re-enter the alt screen on STEP 5 so the operator can pick the tray +/// prompt with the same TUI machinery. +fn advance_to_step5(app: &mut AppState, summary: String) -> Result<()> { + app.wizard_step = WizardStep::ResultAndTray; + app.tray_choice = TrayChoice::No; + app.strategy_result = Some(summary); + app.message = "STEP 5: Up/Down to choose, Enter to confirm, q to quit.".into(); + + enable_raw_mode()?; + let mut stdout_handle = stdout(); + execute!(stdout_handle, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout_handle); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + loop { + terminal.draw(|f| draw_ui(f, app))?; + if !event::poll(Duration::from_millis(200))? { + continue; + } + let evt = event::read()?; + if let Event::Key(key) = evt { + if key.kind == KeyEventKind::Release { + continue; + } + if handle_key(app, key)? { + break; + } + } + } + disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; + + // STEP 5 may have queued a follow-up tray-daemon spawn. + if let Some(PendingAction::StartTrayDaemon) = app.pending_action.take() { + let summary = start_tray_daemon(app)?; + println!("\n{summary}\n"); + } + Ok(()) } diff --git a/src/wizard/persist.rs b/src/wizard/persist.rs index 990c69e..d3f5873 100644 --- a/src/wizard/persist.rs +++ b/src/wizard/persist.rs @@ -1,182 +1,710 @@ -//! Persistence and export functions for the wizard. +//! Persistence and export functions for the 5-step wizard. +//! +//! Three strategies live here, plus the tray-daemon launcher: +//! +//! - `run_unified_generate` β€” Strategy::Unified β€” write +//! `~/.config/mux/{config.toml, mcp.json, mcp.toml}` plus per-client +//! setup snippets. +//! - `run_per_client_generate` β€” Strategy::PerClient β€” one mux config file +//! per originating client kind, in that client's native format. Daemon +//! `config.toml` still merged across every selected source. +//! - `run_danger_auto_configure` β€” Strategy::AutoRewire β€” backup-first +//! preview-first rewrite of the user's existing client configs. +//! - `start_tray_daemon` β€” spawns `rust-mux --tray --multi-service` detached +//! (STEP 5 "Yes β€” start now"). -use std::io::Write; +use std::io::{Write, stdin, stdout}; +use std::process::{Command, Stdio}; use anyhow::{Context, Result}; -use crate::config::{Config, expand_path, safe_copy}; -use crate::scan::{HostFile, HostFormat, rewire_host}; +use crate::config::expand_path; +use crate::danger::{ + DangerStatus, execute_plan, format_preview, plan_danger_rewrite_for_scans, rollback_commands, +}; +use crate::mux_gen::{ + build_mux_outputs, build_per_client_outputs, default_mux_dir, per_client_instructions, + safe_path_instructions, write_mux_outputs, write_per_client_outputs, +}; +use crate::scan::{HostService, MergeOutcome, ScanResult, scan_host_file}; -use super::types::{AppState, ConfirmChoice, HealthCheckChoice, Panel, WizardStep}; +use super::types::{AppState, SourceStatus}; // ───────────────────────────────────────────────────────────────────────────── -// Config building +// Build helpers // ───────────────────────────────────────────────────────────────────────────── -pub fn build_config_for_export(app: &AppState) -> Config { - let mut cfg = Config::default(); +/// Re-scan the operator's selected sources so the strategies see the same +/// services they had on STEP 2. +fn selected_scans(app: &AppState) -> Vec { + let filter = SelectedServiceFilter::from_app(app); + app.sources + .iter() + .filter(|s| s.selected && matches!(s.status, SourceStatus::Ok { .. })) + .filter_map(|s| scan_host_file(&s.host_file).ok()) + .map(|mut scan| { + scan.services.retain(|svc| filter.matches(svc)); + scan + }) + .collect() +} + +/// Build a [`MergeOutcome`] from the operator's STEP 2 selection, restricted +/// to entries the operator left ticked. +fn build_merge_from_services(app: &AppState) -> MergeOutcome { + use crate::scan::HostService; + let mut services = Vec::new(); for svc in &app.services { - if svc.selected { - cfg.servers.insert(svc.name.clone(), svc.config.clone()); + if !svc.selected { + continue; } + services.push(HostService { + name: svc.name.clone(), + command: svc.config.cmd.clone().unwrap_or_default(), + args: svc.config.args.clone().unwrap_or_default(), + cwd: svc.config.cwd.clone(), + socket: svc.config.socket.clone(), + env: svc.config.env.clone(), + enabled: None, + }); + } + MergeOutcome { + services, + conflicts: Vec::new(), } - cfg } -// ───────────────────────────────────────────────────────────────────────────── -// Client rewiring -// ───────────────────────────────────────────────────────────────────────────── +/// Selected sources, filtered to the operator's STEP 2 server selection and +/// restricted to sources eligible for the danger flow. +fn selected_danger_scans(app: &AppState) -> Vec { + selected_scans(app) + .into_iter() + .filter(|scan| scan.host.eligible_for_danger) + .collect() +} -pub fn rewire_selected_clients(app: &AppState) -> Result<()> { - for client in &app.clients { - if client.selected && !client.already_rewired { - let host_file = HostFile { - kind: client.kind, - path: client.config_path.clone(), - format: match client.config_path.extension().and_then(|e| e.to_str()) { - Some("toml") => HostFormat::Toml, - _ => HostFormat::Json, - }, +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ServiceExactKey { + name: String, + shape: ServiceShapeKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ServiceShapeKey { + command: String, + args: Vec, + cwd: Option, + socket: Option, + env: Vec<(String, String)>, +} + +struct SelectedServiceFilter { + exact: std::collections::HashSet, + conflict_shapes: std::collections::HashSet, +} + +impl SelectedServiceFilter { + fn from_app(app: &AppState) -> Self { + let mut exact = std::collections::HashSet::new(); + let mut conflict_shapes = std::collections::HashSet::new(); + + for svc in app.services.iter().filter(|svc| svc.selected) { + let shape = ServiceShapeKey { + command: svc.config.cmd.clone().unwrap_or_default(), + args: svc.config.args.clone().unwrap_or_default(), + cwd: svc.config.cwd.clone(), + socket: svc.config.socket.clone(), + env: sorted_env(svc.config.env.as_ref()), }; + exact.insert(ServiceExactKey { + name: svc.name.clone(), + shape: shape.clone(), + }); - // Use the socket_dir from app state - match rewire_host(&host_file, &app.socket_dir, "rmcp_mux_proxy", &[], false) { - Ok(outcome) => { - if let Some(backup) = outcome.backup { - tracing::info!( - "Rewired {} (backup: {})", - client.config_path.display(), - backup.display() - ); - } - } - Err(e) => { - tracing::warn!("Failed to rewire {}: {}", client.config_path.display(), e); - } + // merge_services renames divergent same-name variants with + // `-from-`, while the source file still carries the + // original name. Shape fallback keeps those explicit selections + // routeable without making the UI store another parallel ID. + if svc.name.contains("-from-") { + conflict_shapes.insert(shape); } } + + Self { + exact, + conflict_shapes, + } } - Ok(()) + + fn matches(&self, svc: &HostService) -> bool { + let shape = ServiceShapeKey { + command: svc.command.clone(), + args: svc.args.clone(), + cwd: svc.cwd.clone(), + socket: svc.socket.clone(), + env: sorted_env(svc.env.as_ref()), + }; + self.exact.contains(&ServiceExactKey { + name: svc.name.clone(), + shape: shape.clone(), + }) || self.conflict_shapes.contains(&shape) + } +} + +fn sorted_env(env: Option<&std::collections::HashMap>) -> Vec<(String, String)> { + let Some(env) = env else { + return Vec::new(); + }; + let mut entries: Vec<(String, String)> = + env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries } // ───────────────────────────────────────────────────────────────────────────── -// Config persistence +// Strategy::Unified // ───────────────────────────────────────────────────────────────────────────── -pub fn persist_all(app: &AppState) -> Result<()> { - let expanded_path = expand_path(app.config_path.to_string_lossy()); +/// Write `~/.config/mux/{config.toml, mcp.json, mcp.toml}` and return the +/// human-readable summary that STEP 5 will display. +pub fn run_unified_generate(app: &AppState) -> Result { + let merge = build_merge_from_services(app); + if merge.services.is_empty() { + return Ok("No services selected β€” nothing generated.".into()); + } + let mux_dir = default_mux_dir(); + let outputs = build_mux_outputs(&merge, &mux_dir, "rust-mux-proxy", &[])?; - // Create parent directory if needed - if let Some(parent) = expanded_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; + if app.dry_run { + let selected = merge + .services + .iter() + .map(|svc| format!(" - {}", svc.name)) + .collect::>() + .join("\n"); + return Ok(format!( + "(dry-run) Would generate:\n - {}\n - {}\n - {}\nPer-client setup commands would be printed on completion.", + outputs.config_toml_path.display(), + outputs.mcp_json_path.display(), + outputs.mcp_toml_path.display() + ) + if selected.is_empty() { + "" + } else { + "\nSelected servers:\n" + } + &selected); } - // Build the config - let mut cfg = Config::default(); - for svc in &app.services { - cfg.servers.insert(svc.name.clone(), svc.config.clone()); - } - - // Serialize based on extension - let serialized = match expanded_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase() - .as_str() - { - "json" => serde_json::to_string_pretty(&cfg)?, - "yaml" | "yml" => serde_yaml::to_string(&cfg)?, - _ => toml::to_string_pretty(&cfg)?, - }; + write_mux_outputs(&outputs)?; - // Create backup if file exists - if expanded_path.exists() { - let backup_path = expanded_path.with_extension("bak"); - safe_copy(&expanded_path, &backup_path)?; + let mut summary = String::new(); + summary.push_str(&format!( + "Wrote rust-mux config under {}:\n", + outputs.mux_dir.display() + )); + summary.push_str(&format!( + " - {} (daemon truth)\n", + outputs.config_toml_path.display() + )); + summary.push_str(&format!( + " - {} (client JSON)\n", + outputs.mcp_json_path.display() + )); + summary.push_str(&format!( + " - {} (client TOML)\n", + outputs.mcp_toml_path.display() + )); + summary.push('\n'); + summary.push_str(&format!( + "Start the mux:\n rust-mux --config {}\n\n", + outputs.config_toml_path.display() + )); + summary.push_str("Use it from your AI clients:\n"); + for inst in safe_path_instructions(&outputs) { + summary.push_str(&format!("β€’ {} ({})\n", inst.headline, inst.kind.as_label())); + for cmd in &inst.commands { + summary.push_str(&format!(" {cmd}\n")); + } + summary.push_str(&format!(" note: {}\n\n", inst.note)); + } + if !outputs.conflicts.is_empty() { + summary.push_str(&format!( + "⚠️ {} server-name conflict(s) surfaced β€” review the config.toml entries.\n", + outputs.conflicts.len() + )); + } + Ok(summary) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Strategy::PerClient +// ───────────────────────────────────────────────────────────────────────────── + +/// Write per-client mux config files plus a shared daemon `config.toml`. +pub fn run_per_client_generate(app: &AppState) -> Result { + let scans = selected_scans(app); + if scans.iter().all(|scan| scan.services.is_empty()) { + return Ok("No selected services parsed cleanly β€” nothing generated.".into()); + } + let mux_dir = default_mux_dir(); + let outputs = build_per_client_outputs(&scans, &mux_dir, "rust-mux-proxy", &[])?; + + if app.dry_run { + let mut s = format!( + "(dry-run) Would write under {}:\n", + outputs.mux_dir.display() + ); + s.push_str(&format!( + " - {} (daemon truth)\n", + outputs.config_toml_path.display() + )); + for client in &outputs.clients { + s.push_str(&format!( + " - {} ({} servers)\n", + client.path.display(), + client.services.len() + )); + } + return Ok(s); } - // Write the config - std::fs::write(&expanded_path, serialized) - .with_context(|| format!("failed to write {}", expanded_path.display()))?; + write_per_client_outputs(&outputs)?; - Ok(()) + let mut summary = String::new(); + summary.push_str(&format!( + "Wrote rust-mux per-client configs under {}:\n", + outputs.mux_dir.display() + )); + summary.push_str(&format!( + " - {} (daemon truth, {} unique servers)\n", + outputs.config_toml_path.display(), + outputs.total_services + )); + for client in &outputs.clients { + summary.push_str(&format!( + " - {} ({} servers)\n", + client.path.display(), + client.services.len() + )); + } + summary.push('\n'); + summary.push_str(&format!( + "Start the mux:\n rust-mux --config {}\n\n", + outputs.config_toml_path.display() + )); + summary.push_str("Use the per-client mux files from each AI client:\n"); + for inst in per_client_instructions(&outputs) { + summary.push_str(&format!("β€’ {} ({})\n", inst.headline, inst.kind.as_label())); + for cmd in &inst.commands { + summary.push_str(&format!(" {cmd}\n")); + } + summary.push_str(&format!(" note: {}\n\n", inst.note)); + } + if !outputs.conflicts.is_empty() { + summary.push_str(&format!( + "⚠️ {} server-name conflict(s) surfaced across clients β€” daemon config kept the variants apart with -from- suffixes.\n", + outputs.conflicts.len() + )); + } + Ok(summary) } // ───────────────────────────────────────────────────────────────────────────── -// Confirm choice execution +// Strategy::AutoRewire (DANGER) // ───────────────────────────────────────────────────────────────────────────── -pub fn execute_confirm_choice(app: &mut AppState) -> Result { - match app.confirm_choice { - ConfirmChoice::SaveAll => { - if !app.dry_run { - // Save mux config - persist_all(app)?; - // Rewire selected clients - rewire_selected_clients(app)?; - } - // Move to health check step - app.wizard_step = WizardStep::HealthCheck; - app.active_panel = Panel::ServiceList; - app.health_choice = HealthCheckChoice::Ok; - app.message = if app.dry_run { - "STEP 4: Health Check (dry-run) - Verify config in your client, then confirm".into() - } else { - "STEP 4: Health Check - Verify config in your client, then confirm here".into() - }; - Ok(false) - } - ConfirmChoice::SaveMuxOnly => { - if !app.dry_run { - persist_all(app)?; +/// Caller is responsible for leaving the TUI's alternate screen and disabling +/// raw mode before invoking this; the function uses cooked stdin/stdout for +/// the explicit `CONFIRM` prompt. +pub fn run_danger_auto_configure(app: &AppState) -> Result { + let merge = build_merge_from_services(app); + if merge.services.is_empty() { + return Ok("No services selected β€” danger flow has nothing to do.".into()); + } + let scans = selected_danger_scans(app); + if scans.is_empty() { + return Ok( + "No selected sources are eligible for danger rewrite (or none parsed cleanly).".into(), + ); + } + + let plan = plan_danger_rewrite_for_scans( + &scans, + "rust-mux-proxy", + &[], + &expand_path("~/.config/mux/sockets"), + ); + + let preview = format_preview(&plan); + println!("\n{preview}"); + + let any_planned = plan + .actions + .iter() + .any(|a| matches!(a.status, DangerStatus::Planned)); + if !any_planned { + println!("(no files planned for change β€” nothing to confirm)\n"); + return Ok("No eligible files to rewrite.".into()); + } + + if app.dry_run { + println!("(dry-run) plan above would have been executed; no files modified.\n"); + return Ok("Dry-run: danger plan rendered, no writes performed.".into()); + } + + println!( + "Type CONFIRM (uppercase) and press Enter to apply the rewrite, anything else to cancel:" + ); + print!("> "); + let _ = stdout().flush(); + let mut input = String::new(); + stdin() + .read_line(&mut input) + .context("read confirmation prompt")?; + + if input.trim() != "CONFIRM" { + println!("Cancelled β€” no files modified.\n"); + return Ok("Danger flow cancelled by operator.".into()); + } + + let outcomes = execute_plan(&plan, true)?; + + println!("\nResults:"); + let mut written = 0usize; + for o in &outcomes { + match &o.status { + DangerStatus::Planned if o.written => { + written += 1; + let backup = o + .backup + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(none)".into()); + println!(" βœ“ wrote {} (backup: {})", o.path.display(), backup); } - // Move to health check step - app.wizard_step = WizardStep::HealthCheck; - app.active_panel = Panel::ServiceList; - app.health_choice = HealthCheckChoice::Ok; - app.message = if app.dry_run { - "STEP 4: Health Check (dry-run) - Verify config in your client, then confirm".into() - } else { - "STEP 4: Health Check - Verify config in your client, then confirm here".into() - }; - Ok(false) - } - ConfirmChoice::CopyToClipboard => { - // Build config string for clipboard - let cfg = build_config_for_export(app); - if let Ok(text) = toml::to_string_pretty(&cfg) { - // Try to copy to clipboard using pbcopy on macOS - if let Ok(mut child) = std::process::Command::new("pbcopy") - .stdin(std::process::Stdio::piped()) - .spawn() - { - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(text.as_bytes()); + other => { + let err = o.error.as_deref().unwrap_or(""); + println!( + " Β· {} skipped ({:?}){}", + o.path.display(), + other, + if err.is_empty() { + String::new() + } else { + format!(": {err}") } - let _ = child.wait(); - app.message = "Configuration copied to clipboard!".into(); - } else { - app.message = "Failed to copy to clipboard (pbcopy not available)".into(); - } - } else { - app.message = "Failed to serialize configuration".into(); + ); } - Ok(false) } - ConfirmChoice::Back => { - // Go back to step 2 - app.wizard_step = WizardStep::ClientSelection; - app.active_panel = Panel::ServiceList; - let selected_count = app.clients.iter().filter(|c| c.selected).count(); - let total_count = app.clients.len(); - app.message = format!( - "STEP 2: {} of {} clients selected | Space: toggle | n: next step | p: previous", - selected_count, total_count - ); - Ok(false) + } + + let rollback = rollback_commands(&outcomes); + if !rollback.is_empty() { + println!("\nRollback (paste any line to restore that file):"); + for cmd in &rollback { + println!(" {cmd}"); } - ConfirmChoice::Exit => Ok(true), + } + + Ok(format!( + "Auto-rewire applied to {written} file(s); see terminal for details and rollback commands." + )) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tray daemon launcher (STEP 5 "Yes β€” start now") +// ───────────────────────────────────────────────────────────────────────────── + +/// Spawn `rust-mux --tray --multi-service` detached from this terminal so the +/// wizard exit doesn't kill the daemon. Returns a short summary line. +pub fn start_tray_daemon(app: &AppState) -> Result { + if app.dry_run { + return Ok("(dry-run) tray daemon would be started in the background.".into()); + } + + // Pick the most useful config target: prefer the freshly generated mux + // config.toml, fall back to the wizard's --config argument. + let mux_config = default_mux_dir().join("config.toml"); + let config_arg = if mux_config.exists() { + mux_config + } else { + app.config_path.clone() + }; + + // Prefer the binary sitting next to the running wizard (covers `cargo run` + // and bespoke install paths); fall back to PATH lookup of `rust-mux`. + let bin = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("rust-mux"))) + .filter(|p| p.exists()) + .map(|p| p.into_os_string()) + .unwrap_or_else(|| std::ffi::OsString::from("rust-mux")); + + let mut cmd = Command::new(&bin); + cmd.arg("--tray") + .arg("--config") + .arg(&config_arg) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + match cmd.spawn() { + Ok(child) => Ok(format!( + "Started tray daemon (pid {}) using config {}", + child.id(), + config_arg.display() + )), + Err(err) => Ok(format!( + "Could not start tray daemon: {err}. Run manually: rust-mux --tray --config {}", + config_arg.display() + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ServerConfig; + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat, HostKind}; + use crate::wizard::types::{ + AppState, CustomPathInput, ServiceEntry, ServiceSource, SourceEntry, Strategy, + SummaryAction, TrayChoice, WizardStep, + }; + use std::fs; + use tempfile::tempdir; + + fn make_app(tmp: &std::path::Path) -> AppState { + AppState { + wizard_step: WizardStep::SummaryConfirm, + config_path: tmp.join("mcp-mux.toml"), + sources: Vec::new(), + selected_source: 0, + custom_path: CustomPathInput::default(), + services: vec![ServiceEntry { + name: "memory".into(), + config: ServerConfig { + socket: Some( + tmp.join("sockets/memory.sock") + .to_string_lossy() + .into_owned(), + ), + cmd: Some("npx".into()), + args: Some(vec!["@modelcontextprotocol/server-memory".into()]), + cwd: None, + env: None, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some("memory".into()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + health: super::super::types::HealthStatus::Unknown, + source: ServiceSource::Client { + kind: HostKind::Custom, + path: tmp.join("custom.json"), + }, + pid: None, + selected: true, + }], + selected_service: 0, + strategy: Strategy::Unified, + summary_action: SummaryAction::Confirm, + tray_choice: TrayChoice::No, + message: String::new(), + dry_run: false, + pending_action: None, + strategy_result: None, + } + } + + fn claude_source(path: std::path::PathBuf) -> HostFile { + HostFile { + kind: HostKind::Claude, + path, + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + fn service_entry(name: &str, package: &str, selected: bool) -> ServiceEntry { + ServiceEntry { + name: name.into(), + config: ServerConfig { + socket: None, + cmd: Some("npx".into()), + args: Some(vec![package.into()]), + cwd: None, + env: None, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(name.into()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + health: super::super::types::HealthStatus::Unknown, + source: ServiceSource::Client { + kind: HostKind::Claude, + path: std::path::PathBuf::from("/tmp/claude.json"), + }, + pid: None, + selected, + } + } + + fn app_with_two_source_services(tmp: &std::path::Path) -> (AppState, std::path::PathBuf) { + let path = tmp.join("claude.json"); + fs::write( + &path, + r#"{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["@modelcontextprotocol/server-memory"] + }, + "brave": { + "command": "npx", + "args": ["@modelcontextprotocol/server-brave-search"] + } + } + }"#, + ) + .expect("write source"); + + let host = claude_source(path.clone()); + let mut app = make_app(tmp); + app.sources = vec![SourceEntry { + host_file: host, + status: SourceStatus::Ok { servers_found: 2 }, + selected: true, + }]; + app.services = vec![ + service_entry("memory", "@modelcontextprotocol/server-memory", true), + service_entry("brave", "@modelcontextprotocol/server-brave-search", false), + ]; + (app, path) + } + + #[test] + fn unified_dry_run_does_not_write_files() { + let dir = tempdir().expect("tempdir"); + let mut app = make_app(dir.path()); + app.dry_run = true; + let summary = run_unified_generate(&app).expect("unified dry"); + assert!( + summary.contains("dry-run") || summary.contains("Would"), + "summary should announce dry-run: {summary}" + ); + } + + #[test] + fn unified_with_no_services_selected_is_noop() { + let dir = tempdir().expect("tempdir"); + let mut app = make_app(dir.path()); + app.services[0].selected = false; + let summary = run_unified_generate(&app).expect("unified empty"); + assert!(summary.contains("No services")); + } + + #[test] + fn per_client_with_no_sources_is_noop() { + let dir = tempdir().expect("tempdir"); + let app = make_app(dir.path()); + let summary = run_per_client_generate(&app).expect("per-client empty"); + assert!( + summary.contains("No selected sources") || summary.contains("nothing generated"), + "summary: {summary}" + ); + } + + #[test] + fn per_client_scans_honor_step2_deselection() { + let dir = tempdir().expect("tempdir"); + let (app, _) = app_with_two_source_services(dir.path()); + + let scans = selected_scans(&app); + + assert_eq!(scans.len(), 1); + assert_eq!(scans[0].services.len(), 1); + assert_eq!(scans[0].services[0].name, "memory"); + } + + #[test] + fn danger_plan_rewrites_only_step2_selected_services() { + let dir = tempdir().expect("tempdir"); + let (app, _) = app_with_two_source_services(dir.path()); + + let scans = selected_danger_scans(&app); + let plan = plan_danger_rewrite_for_scans(&scans, "rust-mux-proxy", &[], dir.path()); + + assert_eq!(plan.actions.len(), 1); + let action = &plan.actions[0]; + assert!(matches!(action.status, DangerStatus::Planned)); + assert_eq!(action.existing_services.len(), 1); + assert_eq!(action.existing_services[0].name, "memory"); + + let rewritten: serde_json::Value = + serde_json::from_str(action.new_contents.as_ref().expect("contents")) + .expect("rewritten json"); + let servers = rewritten + .get("mcpServers") + .and_then(|v| v.as_object()) + .expect("mcpServers"); + assert_eq!( + servers + .get("memory") + .and_then(|v| v.get("command")) + .and_then(|v| v.as_str()), + Some("rust-mux-proxy") + ); + assert_eq!( + servers + .get("brave") + .and_then(|v| v.get("command")) + .and_then(|v| v.as_str()), + Some("npx"), + "deselected servers must stay in the source file untouched" + ); + } + + #[test] + fn danger_with_no_sources_is_noop() { + let dir = tempdir().expect("tempdir"); + let app = make_app(dir.path()); + let summary = run_danger_auto_configure(&app).expect("danger empty"); + assert!( + summary.contains("eligible") || summary.contains("nothing"), + "summary: {summary}" + ); + } + + #[test] + fn start_tray_daemon_dry_run_short_circuits() { + let dir = tempdir().expect("tempdir"); + let mut app = make_app(dir.path()); + app.dry_run = true; + let summary = start_tray_daemon(&app).expect("tray dry"); + assert!(summary.contains("dry-run")); } } diff --git a/src/wizard/services.rs b/src/wizard/services.rs index c321c9e..4ae658a 100644 --- a/src/wizard/services.rs +++ b/src/wizard/services.rs @@ -1,202 +1,331 @@ //! Service loading, detection, and health check logic. +//! +//! v0.4.0 source-of-truth model: +//! +//! 1. **Discovery comes from client configs.** [`load_all_services`] runs +//! [`crate::scan::scan_hosts`] across every well-known MCP client config +//! (Claude / ClaudeDesktop / Codex / Junie / Gemini / Cursor / VSCode / +//! JetBrains) and adds every server it finds, tagged with the originating +//! [`ServiceSource::Client`]. +//! 2. **ps-scan enrichment is optional and last** ([`enrich_running_state`]). +//! It only sets the `pid` field on entries whose `(cmd, args)` match a +//! running process; orphans (running but not in any config) are surfaced +//! as `ServiceSource::DetectedRunning` so the operator can see them. +//! +//! The legacy ps-scan-as-source-of-truth path is gone. The hardcoded +//! `MCP_PATTERNS` whitelist below is used **only** by the enrichment helper +//! to bound the process scan; misses there mean a missed PID badge, never a +//! missing server entry. use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::os::unix::net::UnixStream; -use std::path::Path; use std::process::Command; -use anyhow::Result; +use crate::config::{ServerConfig, expand_path}; +use crate::scan::{HostKind, HostService, ScanResult, merge_services}; -use crate::config::{ServerConfig, expand_path, load_config}; - -use super::types::{FormState, HealthStatus, ServiceEntry, ServiceSource}; +use super::types::{HealthStatus, ServiceEntry, ServiceSource}; // ───────────────────────────────────────────────────────────────────────────── // Service loading // ───────────────────────────────────────────────────────────────────────────── -pub fn load_all_services(path: &Path) -> Result> { - let cfg = load_config(path)?; - let mut services = Vec::new(); - - // Load from config file - if let Some(cfg) = cfg { - for (name, server_cfg) in cfg.servers { - services.push(ServiceEntry { - name, - config: server_cfg, - health: HealthStatus::Unknown, - dirty: false, - source: ServiceSource::Config, - pid: None, - selected: true, - }); +/// Build the wizard's list of `ServiceEntry` from a set of `ScanResult`s. +/// +/// - Runs `merge_services` to dedup identical entries. +/// - Attributes each merged entry to its **first** originating client kind+path +/// (sources are ordered by `default_sources()` priority). +/// - Synthesises a `ServerConfig` with upstream command/args/env only. Socket +/// paths stay `None` unless the source explicitly supplied one; `mux_gen` +/// owns the mux socket path assignment under `~/.config/mux/sockets`. +/// +/// Callers append to the result and run `enrich_running_state` afterwards +/// to stamp PIDs and surface ps-only orphans. +pub fn build_services_from_scans(scans: &[ScanResult]) -> Vec { + let merged = merge_services(scans); + + // Index from (cmd, args, env) -> (HostKind, path). Keeps the first source + // encountered (default_sources is ordered by priority). + let mut origin_index: HashMap = HashMap::new(); + for scan in scans { + for svc in &scan.services { + origin_index + .entry(svc_key(svc)) + .or_insert_with(|| (scan.host.kind, scan.host.path.clone())); } } - // Detect running MCP processes and merge - let detected = detect_running_mcp_servers(); - for mut det in detected { - // Check if we already have this service in config (by matching command+args) - let already_configured = services.iter().any(|s| { - // Match by name or by command+args combination - s.name == det.name - || (s.config.cmd == det.config.cmd && s.config.args == det.config.args) + let mut out = Vec::with_capacity(merged.services.len()); + for svc in &merged.services { + let origin = origin_index + .get(&svc_key(svc)) + .cloned() + .map(|(kind, path)| ServiceSource::Client { kind, path }) + .unwrap_or(ServiceSource::DetectedRunning); + + let config = ServerConfig { + socket: svc.socket.clone(), + cmd: Some(svc.command.clone()), + args: Some(svc.args.clone()), + cwd: svc.cwd.clone(), + env: svc.env.clone(), + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(svc.name.clone()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }; + + out.push(ServiceEntry { + name: svc.name.clone(), + config, + health: HealthStatus::Unknown, + source: origin, + pid: None, + selected: true, }); + } + out +} - if !already_configured { - // Generate a socket path for the detected service - let socket_path = format!("~/mcp-sockets/{}.sock", det.name); - det.config.socket = Some(socket_path); - services.push(det); - } +pub(super) fn append_default_discovered_services(services: &mut Vec) { + if let Some(server) = crate::scan::discover_vibecrafted_mcp() { + let source = server.source; + let svc = server.into_host_service(); + services.push(ServiceEntry { + name: svc.name.clone(), + config: ServerConfig { + socket: svc.socket.clone(), + cmd: Some(svc.command), + args: Some(svc.args), + cwd: svc.cwd, + env: svc.env, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(svc.name), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + health: HealthStatus::Unknown, + source: ServiceSource::Default { source }, + pid: None, + selected: true, + }); } +} - // Sort: config entries first, then detected, both alphabetically - services.sort_by(|a, b| match (&a.source, &b.source) { - (ServiceSource::Config, ServiceSource::Detected) => std::cmp::Ordering::Less, - (ServiceSource::Detected, ServiceSource::Config) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); +fn svc_key(svc: &HostService) -> String { + format!( + "{}|{}|{}|{}", + svc.command, + svc.args.join(" "), + svc.cwd.as_deref().unwrap_or(""), + env_signature(svc.env.as_ref()) + ) +} - Ok(services) +fn env_signature(env: Option<&HashMap>) -> String { + let Some(env) = env else { + return String::new(); + }; + let mut entries: Vec<(&String, &String)> = env.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + entries + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(",") } // ───────────────────────────────────────────────────────────────────────────── -// MCP process detection +// ps-scan enrichment (running-process awareness, *not* discovery) // ───────────────────────────────────────────────────────────────────────────── -/// Patterns that indicate an MCP server process +/// Patterns used to bound the ps-scan to plausible MCP processes. Misses +/// here only mean a missed PID badge β€” discovery itself is driven by +/// [`scan_hosts`] regardless of what is currently running. const MCP_PATTERNS: &[&str] = &[ "@modelcontextprotocol/", "mcp-server-", - "server-memory", - "server-filesystem", - "server-github", - "server-gitlab", - "server-slack", - "server-google-drive", - "server-postgres", - "server-sqlite", - "server-redis", - "server-brave-search", - "server-fetch", - "server-puppeteer", - "server-sequential-thinking", - "claude-mcp", + "-mcp-server", "mcp_server", + "/mcp-", + "-mcp/", + "/loctree-mcp", + "/aicx-mcp", + "claude-mcp", ]; -/// Detect running MCP server processes by scanning `ps` output -pub fn detect_running_mcp_servers() -> Vec { - let mut detected = Vec::new(); +/// Stamp `pid` on every entry whose `(cmd, args)` matches a running process, +/// and append entries for processes that match an MCP heuristic but do not +/// match anything already in the list (`ServiceSource::DetectedRunning`). +pub fn enrich_running_state(services: &mut Vec) { + let running = list_running_mcp_processes(); + if running.is_empty() { + return; + } + + for proc in &running { + // Try to match an existing entry by command+first-arg. + let mut matched = false; + for svc in services.iter_mut() { + if proc_matches_entry(proc, svc) { + svc.pid = Some(proc.pid); + matched = true; + break; + } + } + if !matched { + // Orphan: visible as a running MCP-shaped process but not in any + // discovered config. Surface it so the operator can decide. + services.push(ServiceEntry { + name: proc.synthetic_name.clone(), + config: ServerConfig { + socket: None, + cmd: Some(proc.cmd.clone()), + args: Some(proc.args.clone()), + cwd: None, + env: None, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(proc.synthetic_name.clone()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + health: HealthStatus::Healthy, + source: ServiceSource::DetectedRunning, + pid: Some(proc.pid), + selected: false, + }); + } + } +} + +#[derive(Debug, Clone)] +struct RunningMcpProcess { + pid: u32, + cmd: String, + args: Vec, + synthetic_name: String, +} - // Run `ps -eo pid,args` to get all processes with their arguments +fn list_running_mcp_processes() -> Vec { let output = match Command::new("ps").args(["-eo", "pid,args"]).output() { - Ok(o) => o, - Err(_) => return detected, + Ok(o) if o.status.success() => o, + _ => return Vec::new(), }; - if !output.status.success() { - return detected; - } - let reader = BufReader::new(&output.stdout[..]); let mut seen_names: std::collections::HashSet = std::collections::HashSet::new(); + let mut out = Vec::new(); for line in reader.lines().map_while(Result::ok) { let line = line.trim(); - - // Skip header line if line.starts_with("PID") { continue; } - - // Parse PID and args let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect(); if parts.len() < 2 { continue; } - let pid: u32 = match parts[0].trim().parse() { Ok(p) => p, Err(_) => continue, }; - let args = parts[1].trim(); - // Check if this process matches any MCP pattern - let is_mcp = MCP_PATTERNS.iter().any(|pattern| args.contains(pattern)); - if !is_mcp { + if !MCP_PATTERNS.iter().any(|p| args.contains(p)) { continue; } - - // Skip rmcp_mux itself and its proxy - if args.contains("rmcp_mux") { + // Skip rust-mux itself, its proxy, and the legacy rmcp_mux binary names. + if args.contains("rust-mux") || args.contains("rmcp_mux") { continue; } - // Extract a meaningful name from the process let name = extract_service_name(args); + let unique = ensure_unique_name(&seen_names, name); + seen_names.insert(unique.clone()); - // Ensure unique names - let unique_name = if seen_names.contains(&name) { - let mut counter = 2; - loop { - let candidate = format!("{}-{}", name, counter); - if !seen_names.contains(&candidate) { - break candidate; - } - counter += 1; - } - } else { - name - }; - seen_names.insert(unique_name.clone()); - - // Try to extract command and args from the process line let (cmd, cmd_args) = extract_cmd_and_args(args); + out.push(RunningMcpProcess { + pid, + cmd, + args: cmd_args, + synthetic_name: unique, + }); + } + out +} - let config = ServerConfig { - socket: None, // Will be generated when user configures - cmd: Some(cmd), - args: Some(cmd_args), - env: None, - max_active_clients: Some(5), - tray: Some(false), - service_name: Some(unique_name.clone()), - log_level: Some("info".into()), - lazy_start: Some(false), - max_request_bytes: Some(1_048_576), - request_timeout_ms: Some(30_000), - restart_backoff_ms: Some(1_000), - restart_backoff_max_ms: Some(30_000), - max_restarts: Some(5), - status_file: None, - heartbeat_interval_ms: Some(30_000), - heartbeat_timeout_ms: Some(30_000), - heartbeat_max_failures: Some(3), - heartbeat_enabled: Some(true), - }; +fn proc_matches_entry(proc: &RunningMcpProcess, svc: &ServiceEntry) -> bool { + let svc_cmd = svc.config.cmd.as_deref().unwrap_or(""); + let svc_args = svc.config.args.as_deref().unwrap_or(&[]); + if !cmds_equivalent(&proc.cmd, svc_cmd) { + return false; + } + // Match if the running process args contain the first non-empty service arg + // (heuristic β€” covers `npx -y @x/y` vs `npx @x/y`). + if let Some(probe) = svc_args.iter().find(|a| !a.is_empty()) { + return proc.args.iter().any(|a| a.contains(probe.as_str())); + } + true +} - detected.push(ServiceEntry { - name: unique_name, - config, - health: HealthStatus::Healthy, // It's running, so it's "healthy" - dirty: false, - source: ServiceSource::Detected, - pid: Some(pid), - selected: true, - }); +fn cmds_equivalent(a: &str, b: &str) -> bool { + if a == b { + return true; } + let a_tail = a.rsplit('/').next().unwrap_or(a); + let b_tail = b.rsplit('/').next().unwrap_or(b); + a_tail == b_tail +} - detected +fn ensure_unique_name(used: &std::collections::HashSet, candidate: String) -> String { + if !used.contains(&candidate) { + return candidate; + } + let mut counter = 2usize; + loop { + let next = format!("{candidate}-{counter}"); + if !used.contains(&next) { + return next; + } + counter += 1; + } } -/// Extract a human-readable service name from process arguments fn extract_service_name(args: &str) -> String { - // Try to find @modelcontextprotocol/server-XXX pattern if let Some(idx) = args.find("@modelcontextprotocol/") { let rest = &args[idx + "@modelcontextprotocol/".len()..]; let name: String = rest @@ -207,8 +336,6 @@ fn extract_service_name(args: &str) -> String { return name; } } - - // Try to find mcp-server-XXX pattern if let Some(idx) = args.find("mcp-server-") { let rest = &args[idx + "mcp-server-".len()..]; let name: String = rest @@ -219,8 +346,6 @@ fn extract_service_name(args: &str) -> String { return format!("mcp-{}", name); } } - - // Try to find server-XXX pattern (common MCP naming) if let Some(idx) = args.find("server-") { let rest = &args[idx + "server-".len()..]; let name: String = rest @@ -231,29 +356,20 @@ fn extract_service_name(args: &str) -> String { return name; } } - - // Fallback: use a generic name "detected-mcp".into() } -/// Extract command and arguments from a process command line fn extract_cmd_and_args(args: &str) -> (String, Vec) { let parts: Vec<&str> = args.split_whitespace().collect(); if parts.is_empty() { return ("unknown".into(), vec![]); } - - // Find the main command (npx, node, python, etc.) let cmd = if parts[0].contains('/') { - // Full path - extract just the binary name parts[0].rsplit('/').next().unwrap_or(parts[0]).to_string() } else { parts[0].to_string() }; - - // Everything after the command is args let cmd_args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); - (cmd, cmd_args) } @@ -267,117 +383,88 @@ pub fn check_health(config: &ServerConfig) -> HealthStatus { None => return HealthStatus::Unknown, }; - // Try to connect to the socket synchronously match UnixStream::connect(&socket_path) { Ok(_) => HealthStatus::Healthy, Err(_) => HealthStatus::Unhealthy, } } -// ───────────────────────────────────────────────────────────────────────────── -// Default config and form conversions -// ───────────────────────────────────────────────────────────────────────────── - -pub fn default_server_config() -> ServerConfig { - ServerConfig { - socket: Some("~/mcp-sockets/general-memory.sock".into()), - cmd: Some("npx".into()), - args: Some(vec!["@modelcontextprotocol/server-memory".into()]), - env: None, - max_active_clients: Some(5), - tray: Some(false), - service_name: None, - log_level: Some("info".into()), - lazy_start: Some(false), - max_request_bytes: Some(1_048_576), - request_timeout_ms: Some(30_000), - restart_backoff_ms: Some(1_000), - restart_backoff_max_ms: Some(30_000), - max_restarts: Some(5), - status_file: None, - heartbeat_interval_ms: Some(30_000), - heartbeat_timeout_ms: Some(30_000), - heartbeat_max_failures: Some(3), - heartbeat_enabled: Some(true), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn services_from_merge_attribute_first_source_kind() { + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat}; + let host = HostFile { + kind: HostKind::Claude, + path: std::path::PathBuf::from("/tmp/test-claude.json"), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }; + let scan = ScanResult { + host: host.clone(), + services: vec![HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }; + let entries = build_services_from_scans(std::slice::from_ref(&scan)); + assert_eq!(entries.len(), 1); + match &entries[0].source { + ServiceSource::Client { kind, path } => { + assert_eq!(*kind, HostKind::Claude); + assert_eq!(path, &host.path); + } + other => panic!("expected Client origin, got {other:?}"), + } } -} -pub fn form_from_service(svc: &ServiceEntry) -> FormState { - // Convert env HashMap to "KEY=value KEY2=value2" format - let env_str = svc - .config - .env - .as_ref() - .map(|m| { - m.iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); - - FormState { - service_name: svc.name.clone(), - socket: svc.config.socket.clone().unwrap_or_default(), - cmd: svc.config.cmd.clone().unwrap_or_else(|| "npx".into()), - args: svc.config.args.clone().unwrap_or_default().join(" "), - env: env_str, - max_clients: svc.config.max_active_clients.unwrap_or(5).to_string(), - log_level: svc - .config - .log_level - .clone() - .unwrap_or_else(|| "info".into()), - tray: svc.config.tray.unwrap_or(false), - dirty: false, - } -} + #[test] + fn discovered_service_without_socket_leaves_mux_socket_to_generator() { + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat}; + let scan = ScanResult { + host: HostFile { + kind: HostKind::Claude, + path: std::path::PathBuf::from("/tmp/test-claude.json"), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + }, + services: vec![HostService { + name: "memory".into(), + command: "npx".into(), + args: vec!["@modelcontextprotocol/server-memory".into()], + cwd: None, + socket: None, + env: None, + enabled: None, + }], + }; -pub fn service_from_form(form: &FormState) -> ServerConfig { - let args_vec: Vec = form - .args - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - - // Parse env from "KEY=value KEY2=value2" format - let env_map: HashMap = form - .env - .split_whitespace() - .filter_map(|pair| { - let mut parts = pair.splitn(2, '='); - match (parts.next(), parts.next()) { - (Some(k), Some(v)) if !k.is_empty() => Some((k.to_string(), v.to_string())), - _ => None, - } - }) - .collect(); + let entries = build_services_from_scans(&[scan]); - let env = if env_map.is_empty() { - None - } else { - Some(env_map) - }; + assert_eq!(entries.len(), 1); + assert!( + entries[0].config.socket.is_none(), + "wizard discovery must not inject a fallback socket before mux_gen assigns ~/.config/mux/sockets" + ); + } - ServerConfig { - socket: Some(form.socket.clone()), - cmd: Some(form.cmd.clone()), - args: Some(args_vec), - env, - max_active_clients: form.max_clients.trim().parse().ok(), - tray: Some(form.tray), - service_name: Some(form.service_name.clone()), - log_level: Some(form.log_level.clone()), - lazy_start: Some(false), - max_request_bytes: Some(1_048_576), - request_timeout_ms: Some(30_000), - restart_backoff_ms: Some(1_000), - restart_backoff_max_ms: Some(30_000), - max_restarts: Some(5), - status_file: None, - heartbeat_interval_ms: Some(30_000), - heartbeat_timeout_ms: Some(30_000), - heartbeat_max_failures: Some(3), - heartbeat_enabled: Some(true), + #[test] + fn cmds_equivalent_matches_basename() { + assert!(cmds_equivalent("/usr/local/bin/npx", "npx")); + assert!(cmds_equivalent("npx", "npx")); + assert!(!cmds_equivalent("npx", "node")); } } diff --git a/src/wizard/types.rs b/src/wizard/types.rs index 6de2436..36f0d0d 100644 --- a/src/wizard/types.rs +++ b/src/wizard/types.rs @@ -1,62 +1,119 @@ //! Type definitions for the wizard module. +//! +//! v0.4.0 5-step flow: +//! +//! 1. **DiscoverySources** β€” pick which client config files to scan, plus +//! optional custom paths. +//! 2. **ServerReview** β€” per-client tree of discovered servers, dedup count, +//! conflict hints. Read-only. +//! 3. **StrategyChoice** β€” Unified vs Per-client vs Auto-rewire (DANGER). +//! 4. **SummaryConfirm** β€” preview of what will be written and to where, +//! then Confirm / Back / Cancel. +//! 5. **ResultAndTray** β€” show what was actually done, per-client setup +//! snippets, and offer to start a tray daemon now. use std::path::PathBuf; use crate::config::ServerConfig; -use crate::scan::HostKind; +use crate::scan::{DefaultServerSource, HostFile, HostKind}; // ───────────────────────────────────────────────────────────────────────────── -// Enums +// Wizard flow // ───────────────────────────────────────────────────────────────────────────── -/// Wizard step in the four-step flow +/// Wizard step in the five-step flow. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WizardStep { - /// Step 1: Detect and select MCP servers - ServerSelection, - /// Step 2: Detect and select MCP clients (hosts) - ClientSelection, - /// Step 3: Final confirmation and save options - Confirmation, - /// Step 4: Health check - verify configuration works - HealthCheck, -} - -/// Choice for the health check step + /// Step 1: Choose which client config files to scan, optional custom paths. + DiscoverySources, + /// Step 2: Read-only review of discovered servers, grouped by client. + ServerReview, + /// Step 3: Pick the strategy (Unified / Per-client / Auto-rewire). + StrategyChoice, + /// Step 4: Preview what will happen, confirm or go back. + SummaryConfirm, + /// Step 5: Show the result and offer to start the tray daemon. + ResultAndTray, +} + +/// Strategy on STEP 3. Drives what STEP 4 previews and STEP 5 reports. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HealthCheckChoice { - /// Configuration verified, exit wizard - Ok, - /// Re-run detection and try again - TryAgain, -} - +pub enum Strategy { + /// One unified mux config under `~/.config/mux/{config.toml, mcp.json, mcp.toml}`. + /// Every selected server, deduplicated. Recommended. + Unified, + /// Separate per-client mux configs under `~/.config/mux/.{json,toml}`, + /// only that client's servers. Native format per client. + PerClient, + /// `[DANGER]` Auto-rewire existing client configs in-place to route + /// through `rust-mux-proxy`. Backup-first, preview-first, rollback-ready. + AutoRewire, +} + +/// Action chosen on STEP 4 (SummaryConfirm). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Field { - ServiceName, - Socket, - Cmd, - Args, - Env, - MaxClients, - LogLevel, - Tray, +pub enum SummaryAction { + Confirm, + Back, + Cancel, } +/// Tray daemon prompt on STEP 5. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Panel { - ServiceList, - Editor, - ConfirmDialog, -} - +pub enum TrayChoice { + /// Spawn `rust-mux --tray --multi-service` in the background detached + /// from this terminal. + StartNow, + /// Skip the tray daemon, exit cleanly. + No, +} + +/// Action queued by an in-step Enter handler that needs to run *outside* the +/// raw-mode TUI loop (anything that prints to stdout, prompts via stdin, or +/// spawns a long-running detached process). +/// +/// `keys.rs` sets this and exits the loop; `wizard/mod.rs::run_tui` drains it +/// after restoring the cooked terminal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfirmChoice { - SaveAll, - SaveMuxOnly, - CopyToClipboard, - Back, - Exit, +pub enum PendingAction { + /// Strategy::Unified β€” write `~/.config/mux/{config.toml, mcp.json, mcp.toml}`. + GenerateUnified, + /// Strategy::PerClient β€” write per-kind native files in `~/.config/mux/`. + GeneratePerClient, + /// Strategy::AutoRewire β€” backup-first preview-first rewrite of existing + /// client configs to route through `rust-mux-proxy`. + AutoRewire, + /// Spawn the tray daemon detached; runs after the strategy result is + /// printed. + StartTrayDaemon, +} + +/// Source of a `ServiceEntry`. Drives UI labels and dedup decisions. +/// +/// Custom-path imports (`--import-config`, wizard custom-path field) flow +/// through `scan::host_file_from_custom_path`, which tags the host file with +/// `HostKind::Custom`; the resulting `ServiceEntry::source` is therefore +/// `ServiceSource::Client { kind: HostKind::Custom, .. }`. There is no +/// separate `Custom` variant β€” that would be a parallel surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ServiceSource { + /// Discovered inside a known MCP client config file (well-known clients + /// or `HostKind::Custom` for user-provided paths). + Client { kind: HostKind, path: PathBuf }, + /// Built-in discovered server, such as the local Vibecrafted MCP package. + Default { source: DefaultServerSource }, + /// Detected as a running process but not present in any scanned config. + DetectedRunning, +} + +impl ServiceSource { + pub fn short_label(&self) -> String { + match self { + ServiceSource::Client { kind, .. } => kind.as_label().to_string(), + ServiceSource::Default { .. } => "default".into(), + ServiceSource::DetectedRunning => "running".into(), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -66,150 +123,102 @@ pub enum HealthStatus { Unhealthy, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ServiceSource { - /// Loaded from config file - Config, - /// Detected from running process - Detected, +// ───────────────────────────────────────────────────────────────────────────── +// Source-step state (STEP 1) +// ───────────────────────────────────────────────────────────────────────────── + +/// Status of a discovery source after we tried to read it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceStatus { + /// File present and parsed cleanly. + Ok { servers_found: usize }, + /// File present but contained no MCP server entries. + Empty, + /// File present but failed to parse β€” `details` carries the error. + InvalidFormat { details: String }, + /// File not present on disk. + Missing, +} + +impl SourceStatus { + pub fn short_label(&self) -> String { + match self { + SourceStatus::Ok { servers_found } => format!("{servers_found} servers"), + SourceStatus::Empty => "empty".into(), + SourceStatus::InvalidFormat { .. } => "invalid".into(), + SourceStatus::Missing => "not found".into(), + } + } +} + +/// One row on STEP 1: a candidate source the operator can include or skip. +#[derive(Debug, Clone)] +pub struct SourceEntry { + pub host_file: HostFile, + pub status: SourceStatus, + /// Whether the operator wants this source included. + pub selected: bool, } // ───────────────────────────────────────────────────────────────────────────── -// Structs +// Service-review state (STEP 2) // ───────────────────────────────────────────────────────────────────────────── +/// One service entry surfaced in STEP 2. Always selected by default; the +/// operator can untick to drop it from the mux output. #[derive(Debug, Clone)] pub struct ServiceEntry { pub name: String, pub config: ServerConfig, pub health: HealthStatus, - pub dirty: bool, pub source: ServiceSource, - /// PID of running process (if detected) + /// PID of running process (set by ps-scan enrichment). pub pid: Option, - /// Whether this server is selected for inclusion in mux config - pub selected: bool, -} - -/// Represents a detected MCP client (host application) -#[derive(Debug, Clone)] -pub struct ClientEntry { - /// Host kind (Codex, Cursor, VSCode, Claude, JetBrains) - pub kind: HostKind, - /// Path to the client's config file - pub config_path: PathBuf, - /// Whether this client is selected for rewiring + /// Whether this server is selected for inclusion in mux config. pub selected: bool, - /// Services defined in this client's config - pub services: Vec, - /// Whether the client is already rewired to use rmcp_mux - pub already_rewired: bool, - /// Whether the config file exists (client may be installed but without MCP config) - pub config_exists: bool, } -#[derive(Debug, Clone)] -pub struct FormState { - pub service_name: String, - pub socket: String, - pub cmd: String, - pub args: String, - pub env: String, - pub max_clients: String, - pub log_level: String, - pub tray: bool, - pub dirty: bool, -} +// ───────────────────────────────────────────────────────────────────────────── +// AppState β€” the wizard's working set +// ───────────────────────────────────────────────────────────────────────────── -impl Default for FormState { - fn default() -> Self { - Self { - service_name: String::new(), - socket: String::new(), - cmd: "npx".into(), - args: "@modelcontextprotocol/server-memory".into(), - env: String::new(), - max_clients: "5".into(), - log_level: "info".into(), - tray: false, - dirty: false, - } - } +/// Editing the custom-path input field on STEP 1. +#[derive(Debug, Clone, Default)] +pub struct CustomPathInput { + pub buffer: String, + /// Whether we are currently in raw-keystroke editing of the buffer. + pub editing: bool, + /// Latest validation message ("file not found", "parsed N servers", ...) + pub status: Option, } pub struct AppState { - /// Current wizard step + /// Current wizard step. pub wizard_step: WizardStep, - /// Path to mux config file + /// Path to mux config file (for the legacy mux-only persist path). pub config_path: PathBuf, - /// Socket directory for mux services - pub socket_dir: PathBuf, - /// Detected/configured MCP servers + /// Discovery sources surfaced on STEP 1. + pub sources: Vec, + /// Currently highlighted source on STEP 1. + pub selected_source: usize, + /// Custom-path input on STEP 1. + pub custom_path: CustomPathInput, + /// All discovered services (computed when leaving STEP 1). pub services: Vec, - /// Currently highlighted server in list + /// Currently highlighted service on STEP 2. pub selected_service: usize, - /// Detected MCP clients (hosts) - pub clients: Vec, - /// Currently highlighted client in list - pub selected_client: usize, - /// Form state for editing - pub form: FormState, - pub current_field: Field, - pub editing: Option, - pub active_panel: Panel, - pub confirm_choice: ConfirmChoice, - /// Health check step choice - pub health_choice: HealthCheckChoice, + /// STEP 3 strategy radio. + pub strategy: Strategy, + /// STEP 4 confirm choice. + pub summary_action: SummaryAction, + /// STEP 5 tray prompt. + pub tray_choice: TrayChoice, + /// Status bar message. pub message: String, pub dry_run: bool, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Navigation helpers -// ───────────────────────────────────────────────────────────────────────────── - -pub fn previous_field(current: Field) -> Field { - match current { - Field::ServiceName => Field::Tray, - Field::Socket => Field::ServiceName, - Field::Cmd => Field::Socket, - Field::Args => Field::Cmd, - Field::Env => Field::Args, - Field::MaxClients => Field::Env, - Field::LogLevel => Field::MaxClients, - Field::Tray => Field::LogLevel, - } -} - -pub fn next_field(current: Field) -> Field { - match current { - Field::ServiceName => Field::Socket, - Field::Socket => Field::Cmd, - Field::Cmd => Field::Args, - Field::Args => Field::Env, - Field::Env => Field::MaxClients, - Field::MaxClients => Field::LogLevel, - Field::LogLevel => Field::Tray, - Field::Tray => Field::ServiceName, - } -} - -pub fn previous_confirm_choice(current: ConfirmChoice) -> ConfirmChoice { - match current { - ConfirmChoice::SaveAll => ConfirmChoice::Exit, - ConfirmChoice::SaveMuxOnly => ConfirmChoice::SaveAll, - ConfirmChoice::CopyToClipboard => ConfirmChoice::SaveMuxOnly, - ConfirmChoice::Back => ConfirmChoice::CopyToClipboard, - ConfirmChoice::Exit => ConfirmChoice::Back, - } -} - -pub fn next_confirm_choice(current: ConfirmChoice) -> ConfirmChoice { - match current { - ConfirmChoice::SaveAll => ConfirmChoice::SaveMuxOnly, - ConfirmChoice::SaveMuxOnly => ConfirmChoice::CopyToClipboard, - ConfirmChoice::CopyToClipboard => ConfirmChoice::Back, - ConfirmChoice::Back => ConfirmChoice::Exit, - ConfirmChoice::Exit => ConfirmChoice::SaveAll, - } + /// Action to perform after the TUI loop exits and the terminal is back + /// to cooked mode. + pub pending_action: Option, + /// Free-text result that STEP 5 displays after the strategy ran. + pub strategy_result: Option, } diff --git a/src/wizard/ui.rs b/src/wizard/ui.rs index 01f76c4..70349b8 100644 --- a/src/wizard/ui.rs +++ b/src/wizard/ui.rs @@ -1,20 +1,19 @@ -//! UI drawing functions for the wizard TUI. +//! UI drawing functions for the wizard TUI (5-step flow). use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use crate::scan::HostKind; use super::types::{ - AppState, ConfirmChoice, Field, HealthCheckChoice, HealthStatus, Panel, ServiceSource, - WizardStep, + AppState, ServiceSource, SourceStatus, Strategy, SummaryAction, TrayChoice, WizardStep, }; // ───────────────────────────────────────────────────────────────────────────── -// Main draw function +// Top-level draw // ───────────────────────────────────────────────────────────────────────────── pub fn draw_ui(f: &mut Frame, app: &AppState) { @@ -22,22 +21,23 @@ pub fn draw_ui(f: &mut Frame, app: &AppState) { .direction(Direction::Vertical) .margin(1) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Main area (two columns) + Constraint::Length(3), // Title bar + Constraint::Min(10), // Body Constraint::Length(3), // Status bar ]) .split(f.area()); - // Title with step indicator + // Title bar let step_info = match app.wizard_step { - WizardStep::ServerSelection => "Step 1/4: Server Detection", - WizardStep::ClientSelection => "Step 2/4: Client Detection", - WizardStep::Confirmation => "Step 3/4: Confirmation", - WizardStep::HealthCheck => "Step 4/4: Health Check", + WizardStep::DiscoverySources => "Step 1/5: Discovery Sources", + WizardStep::ServerReview => "Step 2/5: Server Review", + WizardStep::StrategyChoice => "Step 3/5: Strategy Choice", + WizardStep::SummaryConfirm => "Step 4/5: Summary & Confirm", + WizardStep::ResultAndTray => "Step 5/5: Result & Tray Daemon", }; let title = Paragraph::new(Line::from(vec![ Span::styled( - "rmcp_mux wizard", + "rust-mux wizard", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" β€” "), @@ -45,33 +45,14 @@ pub fn draw_ui(f: &mut Frame, app: &AppState) { ])); f.render_widget(title, chunks[0]); - // Main area: two columns - let main_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(35), // Left: list - Constraint::Percentage(65), // Right: editor/details - ]) - .split(chunks[1]); - - // Draw appropriate content based on wizard step + // Body + let body = chunks[1]; match app.wizard_step { - WizardStep::ServerSelection => { - draw_service_list(f, app, main_chunks[0]); - draw_editor(f, app, main_chunks[1]); - } - WizardStep::ClientSelection => { - draw_client_list(f, app, main_chunks[0]); - draw_client_details(f, app, main_chunks[1]); - } - WizardStep::Confirmation => { - draw_summary(f, app, main_chunks[0]); - draw_save_options(f, app, main_chunks[1]); - } - WizardStep::HealthCheck => { - draw_health_check_info(f, app, main_chunks[0]); - draw_health_check_options(f, app, main_chunks[1]); - } + WizardStep::DiscoverySources => draw_step1_sources(f, app, body), + WizardStep::ServerReview => draw_step2_review(f, app, body), + WizardStep::StrategyChoice => draw_step3_strategy(f, app, body), + WizardStep::SummaryConfirm => draw_step4_summary(f, app, body), + WizardStep::ResultAndTray => draw_step5_result(f, app, body), } // Status bar @@ -86,723 +67,735 @@ pub fn draw_ui(f: &mut Frame, app: &AppState) { .wrap(Wrap { trim: true }) .block(Block::default().borders(Borders::ALL).title("Status")); f.render_widget(status, chunks[2]); - - // Draw confirm dialog if active - if app.active_panel == Panel::ConfirmDialog { - draw_confirm_dialog(f, app); - } } // ───────────────────────────────────────────────────────────────────────────── -// Service list (Step 1) +// STEP 1: Discovery sources // ───────────────────────────────────────────────────────────────────────────── -pub fn draw_service_list(f: &mut Frame, app: &AppState, area: Rect) { - let is_active = - app.active_panel == Panel::ServiceList && app.wizard_step == WizardStep::ServerSelection; - let border_style = if is_active { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; +fn draw_step1_sources(f: &mut Frame, app: &AppState, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); - // Count services by source and selection for title - let selected_count = app.services.iter().filter(|s| s.selected).count(); - let total_count = app.services.len(); - let title = format!("STEP 1: Servers [{}/{}]", selected_count, total_count); + // Left: list of candidate sources. + let selected_count = app.sources.iter().filter(|s| s.selected).count(); + let total_count = app.sources.len(); + let title = format!("Sources [{}/{}]", selected_count, total_count); let items: Vec = app - .services + .sources .iter() .enumerate() - .map(|(i, svc)| { - // Selection checkbox - let checkbox = if svc.selected { + .map(|(i, src)| { + let checkbox = if src.selected { Span::styled("[x] ", Style::default().fg(Color::Green)) } else { Span::styled("[ ] ", Style::default().fg(Color::DarkGray)) }; - - // Source indicator: config file vs detected process - let source_indicator = match svc.source { - ServiceSource::Config => Span::styled("[C]", Style::default().fg(Color::Blue)), - ServiceSource::Detected => Span::styled("[D]", Style::default().fg(Color::Magenta)), - }; - - // Health indicator - let health_indicator = match svc.health { - HealthStatus::Healthy => Span::styled(" ● ", Style::default().fg(Color::Green)), - HealthStatus::Unhealthy => Span::styled(" ● ", Style::default().fg(Color::Red)), - HealthStatus::Unknown => Span::styled(" β—‹ ", Style::default().fg(Color::DarkGray)), - }; - - let name_style = if i == app.selected_service { - if is_active { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD) - } - } else { - Style::default() + let kind_tag = Span::styled( + format!("[{:<14}]", src.host_file.kind.display_name()), + Style::default().fg(kind_color(src.host_file.kind)), + ); + let path_span = Span::raw(src.host_file.path.display().to_string()); + let status_color = match src.status { + SourceStatus::Ok { .. } => Color::Green, + SourceStatus::Empty => Color::DarkGray, + SourceStatus::InvalidFormat { .. } => Color::Red, + SourceStatus::Missing => Color::DarkGray, }; - - let dirty_marker = if svc.dirty { - Span::styled(" *", Style::default().fg(Color::Yellow)) + let status_span = Span::styled( + format!(" {}", src.status.short_label()), + Style::default().fg(status_color), + ); + let highlight = if i == app.selected_source { + Span::styled(" β–Ά ", Style::default().fg(Color::Yellow)) } else { - Span::raw("") - }; - - // Show PID for detected processes - let pid_info = match (svc.source, svc.pid) { - (ServiceSource::Detected, Some(pid)) => { - Span::styled(format!(" ({})", pid), Style::default().fg(Color::DarkGray)) - } - _ => Span::raw(""), + Span::raw(" ") }; ListItem::new(Line::from(vec![ + highlight, checkbox, - source_indicator, - health_indicator, - Span::styled(&svc.name, name_style), - dirty_marker, - pid_info, + kind_tag, + Span::raw(" "), + path_span, + status_span, ])) }) .collect(); - let list = List::new(items).block( Block::default() .borders(Borders::ALL) - .border_style(border_style) + .border_style(Style::default().fg(Color::Cyan)) .title(title), ); + f.render_widget(list, columns[0]); - f.render_widget(list, area); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Editor panel (Step 1) -// ───────────────────────────────────────────────────────────────────────────── - -pub fn draw_editor(f: &mut Frame, app: &AppState, area: Rect) { - let is_active = app.active_panel == Panel::Editor; - let border_style = if is_active { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let fields = vec![ - (Field::ServiceName, "Service name", &app.form.service_name), - (Field::Socket, "Socket", &app.form.socket), - (Field::Cmd, "Command", &app.form.cmd), - (Field::Args, "Args", &app.form.args), - (Field::Env, "Env vars", &app.form.env), - (Field::MaxClients, "Max clients", &app.form.max_clients), - (Field::LogLevel, "Log level", &app.form.log_level), + // Right: custom-path input + key hints. + let mut lines = vec![ + Line::from(Span::styled( + "Add custom path", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), ]; - - let mut lines: Vec = fields - .into_iter() - .map(|(field, label, value)| { - let label_style = Style::default().fg(Color::Cyan); - let val_style = if Some(field) == app.editing { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else if field == app.current_field && is_active { - Style::default().fg(Color::Green) - } else { - Style::default() - }; - - Line::from(vec![ - Span::styled(format!("{label:<14}"), label_style), - Span::styled(value.clone(), val_style), - ]) - }) - .collect(); - - // Tray field - let tray_label = if app.form.tray { "true" } else { "false" }; - let tray_style = if Some(Field::Tray) == app.editing { + let buf_style = if app.custom_path.editing { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) - } else if app.current_field == Field::Tray && is_active { - Style::default().fg(Color::Green) } else { Style::default() }; - lines.push(Line::from(vec![ - Span::styled("Tray enabled ", Style::default().fg(Color::Cyan)), - Span::styled(tray_label, tray_style), - ])); - - let editor = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title("Editor"), - ); - - f.render_widget(editor, area); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Client list (Step 2) -// ───────────────────────────────────────────────────────────────────────────── - -pub fn draw_client_list(f: &mut Frame, app: &AppState, area: Rect) { - let is_active = - app.active_panel == Panel::ServiceList && app.wizard_step == WizardStep::ClientSelection; - let border_style = if is_active { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let selected_count = app.clients.iter().filter(|c| c.selected).count(); - let total_count = app.clients.len(); - let title = format!("STEP 2: Clients [{}/{}]", selected_count, total_count); - - let items: Vec = app - .clients - .iter() - .enumerate() - .map(|(i, client)| { - // Selection checkbox - let checkbox = if client.selected { - Span::styled("[x] ", Style::default().fg(Color::Green)) - } else { - Span::styled("[ ] ", Style::default().fg(Color::DarkGray)) - }; - - // Host kind indicator - let kind_label = match client.kind { - HostKind::Codex => Span::styled("Codex", Style::default().fg(Color::Blue)), - HostKind::Cursor => Span::styled("Cursor", Style::default().fg(Color::Magenta)), - HostKind::VSCode => Span::styled("VSCode", Style::default().fg(Color::Cyan)), - HostKind::Claude => Span::styled("Claude", Style::default().fg(Color::Yellow)), - HostKind::JetBrains => Span::styled("JetBrains", Style::default().fg(Color::Green)), - HostKind::Unknown => Span::styled("Unknown", Style::default().fg(Color::DarkGray)), - }; - - // Rewired status indicator - let status = if !client.config_exists { - Span::styled(" [no config]", Style::default().fg(Color::Red)) - } else if client.already_rewired { - Span::styled(" [rewired]", Style::default().fg(Color::Green)) - } else { - Span::styled(" [not rewired]", Style::default().fg(Color::Yellow)) - }; - - let name_style = if i == app.selected_client { - if is_active { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - } - } else { - Style::default() - }; - - // Service count - let svc_count = Span::styled( - format!(" ({} svcs)", client.services.len()), - Style::default().fg(Color::DarkGray), - ); - - ListItem::new(Line::from(vec![ - checkbox, - Span::styled("", name_style), // Apply style context - kind_label, - status, - svc_count, - ])) - }) - .collect(); - - if items.is_empty() { - let empty_msg = Paragraph::new( - "No MCP clients detected.\nSupported: Codex, Cursor, VSCode, Claude, JetBrains", - ) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(title), - ) - .wrap(Wrap { trim: true }); - f.render_widget(empty_msg, area); - return; - } - - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(title), - ); - - f.render_widget(list, area); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Client details panel (Step 2) -// ───────────────────────────────────────────────────────────────────────────── - -pub fn draw_client_details(f: &mut Frame, app: &AppState, area: Rect) { - let is_active = - app.active_panel == Panel::Editor && app.wizard_step == WizardStep::ClientSelection; - let border_style = if is_active { - Style::default().fg(Color::Cyan) + let buf_display = if app.custom_path.buffer.is_empty() { + "".to_string() } else { - Style::default().fg(Color::DarkGray) + app.custom_path.buffer.clone() }; - - let mut lines: Vec = vec![ - Line::from(Span::styled( - "Client Configuration Details", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(""), - ]; - - if app.clients.is_empty() { - lines.push(Line::from("No clients detected.")); - lines.push(Line::from("")); - lines.push(Line::from("The wizard searches for MCP client configs in:")); - lines.push(Line::from(" β€’ ~/.codex/config.toml (Codex)")); - lines.push(Line::from(" β€’ ~/Library/.../Cursor/settings.json")); - lines.push(Line::from(" β€’ ~/Library/.../Code/settings.json (VSCode)")); - lines.push(Line::from(" β€’ ~/.config/Claude/claude_config.json")); - lines.push(Line::from(" β€’ ~/Library/.../JetBrains/LLM/mcp.json")); - } else if app.selected_client < app.clients.len() { - let client = &app.clients[app.selected_client]; - - lines.push(Line::from(vec![ - Span::styled("Host: ", Style::default().fg(Color::Cyan)), - Span::raw(client.kind.as_label()), - ])); - lines.push(Line::from(vec![ - Span::styled("Config: ", Style::default().fg(Color::Cyan)), - Span::raw(client.config_path.display().to_string()), - ])); - - // Config existence status - if !client.config_exists { - lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Color::Cyan)), - Span::styled( - "No MCP config file (app installed)", - Style::default().fg(Color::Red), - ), - ])); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "A new MCP config will be created for this client.", - Style::default().fg(Color::Yellow), - ))); + lines.push(Line::from(vec![ + Span::raw("> "), + Span::styled(buf_display, buf_style), + if app.custom_path.editing { + Span::styled("_", Style::default().fg(Color::Yellow)) } else { - lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Color::Cyan)), - if client.already_rewired { - Span::styled( - "Already rewired to rmcp_mux", - Style::default().fg(Color::Green), - ) - } else { - Span::styled("Not yet rewired", Style::default().fg(Color::Yellow)) - }, - ])); - } - + Span::raw("") + }, + ])); + if let Some(status) = &app.custom_path.status { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Services in this client:", - Style::default().add_modifier(Modifier::BOLD), + status.clone(), + Style::default().fg(Color::DarkGray), ))); - - if client.services.is_empty() { - if client.config_exists { - lines.push(Line::from(" (no MCP services defined yet)")); - } else { - lines.push(Line::from(" (config will be created with mux services)")); - } - } else { - for svc in &client.services { - lines.push(Line::from(format!(" β€’ {}", svc))); - } - } - - lines.push(Line::from("")); - if client.selected { - if client.config_exists { - lines.push(Line::from(Span::styled( - "This client will be rewired to use rmcp_mux proxy.", - Style::default().fg(Color::Green), - ))); - } else { - lines.push(Line::from(Span::styled( - "A new MCP config will be created for this client.", - Style::default().fg(Color::Green), - ))); - } - } else { - lines.push(Line::from(Span::styled( - "This client will NOT be modified.", - Style::default().fg(Color::DarkGray), - ))); - } } - - let details = Paragraph::new(lines) + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Keys", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(" Up/Down navigate sources")); + lines.push(Line::from(" Space toggle selection")); + lines.push(Line::from(" i edit custom path")); + lines.push(Line::from(" Enter add custom path")); + lines.push(Line::from(" n next step")); + lines.push(Line::from(" q quit")); + + let panel = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title("Details"), + .border_style(Style::default().fg(Color::DarkGray)) + .title("Custom path"), ) .wrap(Wrap { trim: true }); - - f.render_widget(details, area); + f.render_widget(panel, columns[1]); } // ───────────────────────────────────────────────────────────────────────────── -// Summary panel (Step 3) +// STEP 2: Server review // ───────────────────────────────────────────────────────────────────────────── -pub fn draw_summary(f: &mut Frame, app: &AppState, area: Rect) { - let border_style = Style::default().fg(Color::Cyan); - - let selected_servers: Vec<&str> = app - .services - .iter() - .filter(|s| s.selected) - .map(|s| s.name.as_str()) - .collect(); - - let selected_clients: Vec<&str> = app - .clients - .iter() - .filter(|c| c.selected) - .map(|c| c.kind.as_label()) - .collect(); +fn draw_step2_review(f: &mut Frame, app: &AppState, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area); + + // Left: per-client tree of services with checkboxes. + let mut items: Vec = Vec::new(); + let mut current_kind: Option = None; + for (idx, svc) in app.services.iter().enumerate() { + // Group separator when the source kind changes. + let same_group = match (¤t_kind, &svc.source) { + (Some(a), b) => a == b, + (None, _) => false, + }; + if !same_group { + current_kind = Some(svc.source.clone()); + items.push(ListItem::new(Line::from(vec![Span::styled( + format!("─ {} ", svc.source.short_label()), + Style::default().fg(Color::DarkGray), + )]))); + } + let checkbox = if svc.selected { + Span::styled("[x] ", Style::default().fg(Color::Green)) + } else { + Span::styled("[ ] ", Style::default().fg(Color::DarkGray)) + }; + let highlight = if idx == app.selected_service { + Span::styled("β–Ά ", Style::default().fg(Color::Yellow)) + } else { + Span::raw(" ") + }; + let kind_color = match &svc.source { + ServiceSource::Client { kind, .. } => kind_color_value(*kind), + ServiceSource::Default { .. } => Color::Cyan, + ServiceSource::DetectedRunning => Color::Magenta, + }; + let pid_span = match svc.pid { + Some(pid) => Span::styled( + format!(" (pid {pid})"), + Style::default().fg(Color::DarkGray), + ), + None => Span::raw(""), + }; + items.push(ListItem::new(Line::from(vec![ + highlight, + checkbox, + Span::styled(svc.name.clone(), Style::default().fg(kind_color)), + pid_span, + ]))); + } + if items.is_empty() { + items.push(ListItem::new(Line::from(Span::styled( + "No servers discovered. Go back (p) and add sources.", + Style::default().fg(Color::Yellow), + )))); + } + let selected_count = app.services.iter().filter(|s| s.selected).count(); + let title = format!("Servers [{}/{}]", selected_count, app.services.len()); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(title), + ); + f.render_widget(list, columns[0]); - let mut lines: Vec = vec![ + // Right: summary + dedup + key hints. + let unique_names: std::collections::HashSet<&str> = + app.services.iter().map(|s| s.name.as_str()).collect(); + let mut lines = vec![ Line::from(Span::styled( - "Configuration Summary", + "Summary", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), + Line::from(format!(" Total entries : {}", app.services.len())), + Line::from(format!(" Unique names : {}", unique_names.len())), + Line::from(format!( + " Sources scanned: {}", + app.sources.iter().filter(|s| s.selected).count() + )), + Line::from(""), Line::from(Span::styled( - format!("Selected Servers ({})", selected_servers.len()), - Style::default().fg(Color::Cyan), + "Keys", + Style::default().add_modifier(Modifier::BOLD), )), + Line::from(" Up/Down navigate"), + Line::from(" Space toggle selection"), + Line::from(" n next step"), + Line::from(" p previous step"), + Line::from(" q quit"), ]; - for name in &selected_servers { - lines.push(Line::from(format!(" [x] {}", name))); - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - format!("Selected Clients ({})", selected_clients.len()), - Style::default().fg(Color::Cyan), - ))); - - for name in &selected_clients { - lines.push(Line::from(format!(" [x] {}", name))); + if app.services.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "No services discovered from selected sources.", + Style::default().fg(Color::Yellow), + ))); } - let summary = Paragraph::new(lines) + let panel = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title("STEP 3: Summary"), + .border_style(Style::default().fg(Color::DarkGray)) + .title("Review"), ) .wrap(Wrap { trim: true }); - - f.render_widget(summary, area); + f.render_widget(panel, columns[1]); } // ───────────────────────────────────────────────────────────────────────────── -// Save options panel (Step 3) +// STEP 3: Strategy // ───────────────────────────────────────────────────────────────────────────── -pub fn draw_save_options(f: &mut Frame, app: &AppState, area: Rect) { - let border_style = Style::default().fg(Color::Cyan); - +fn draw_step3_strategy(f: &mut Frame, app: &AppState, area: Rect) { let choices = [ ( - ConfirmChoice::SaveAll, - "Save All", - "Save mux config AND rewire selected clients", + Strategy::Unified, + "Unified config", + "Write one ~/.config/mux/{config.toml,mcp.json,mcp.toml} with every selected server. Recommended.", ), ( - ConfirmChoice::SaveMuxOnly, - "Mux Only", - "Save mux config only (no client rewiring)", + Strategy::PerClient, + "Per-client configs", + "Write a separate file per client kind (claude.json, codex.toml, junie.json, ...) under ~/.config/mux/.", ), ( - ConfirmChoice::CopyToClipboard, - "Clipboard", - "Copy config to clipboard", + Strategy::AutoRewire, + "[DANGER] Auto-rewire existing client configs", + "Backup-first preview-first rewrite of your real client configs to route through rust-mux-proxy.", ), - (ConfirmChoice::Back, "Back", "Return to previous step"), - (ConfirmChoice::Exit, "Exit", "Exit without saving"), ]; let mut lines: Vec = vec![ Line::from(Span::styled( - "Save Options", + "How do you want to use mux?", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from("Use Up/Down to select, Enter to confirm:"), - Line::from(""), ]; - - for (choice, label, description) in choices { - let is_selected = choice == app.confirm_choice; - let prefix = if is_selected { "β–Ά " } else { " " }; - let style = if is_selected { + for (idx, (choice, label, description)) in choices.iter().enumerate() { + let is_selected = *choice == app.strategy; + let marker = if is_selected { "(β€’)" } else { "( )" }; + let label_style = if is_selected { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) } else { Style::default() }; - + let danger = matches!(choice, Strategy::AutoRewire); + let label_color = if danger { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + label_style + }; lines.push(Line::from(vec![ - Span::styled(prefix, style), - Span::styled(format!("[{}]", label), style), - Span::raw(" - "), - Span::styled(description, Style::default().fg(Color::DarkGray)), + Span::raw(format!(" {marker} ")), + Span::styled( + format!("{}. ", idx + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(label.to_string(), label_color), ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + description.to_string(), + Style::default().fg(Color::DarkGray), + ), + ])); + lines.push(Line::from("")); } + lines.push(Line::from(Span::styled( + "Up/Down to choose, Enter or n to continue, p to go back, q to quit.", + Style::default().fg(Color::DarkGray), + ))); - lines.push(Line::from("")); - if app.dry_run { - lines.push(Line::from(Span::styled( - "DRY-RUN MODE: No files will be modified", - Style::default().fg(Color::Yellow), - ))); - } else { - lines.push(Line::from(Span::styled( - "Backups will be created for all modified files (.bak)", - Style::default().fg(Color::Green), - ))); - } - - let options = Paragraph::new(lines) + let panel = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title("Actions"), + .border_style(Style::default().fg(Color::Cyan)) + .title("Strategy"), ) .wrap(Wrap { trim: true }); - - f.render_widget(options, area); + f.render_widget(panel, area); } // ───────────────────────────────────────────────────────────────────────────── -// Health check panel (Step 4) +// STEP 4: Summary + confirm // ───────────────────────────────────────────────────────────────────────────── -pub fn draw_health_check_info(f: &mut Frame, app: &AppState, area: Rect) { - let border_style = Style::default().fg(Color::Cyan); +fn draw_step4_summary(f: &mut Frame, app: &AppState, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(area); - let selected_servers: Vec<&str> = app + let strategy_label = match app.strategy { + Strategy::Unified => "Unified", + Strategy::PerClient => "Per-client configs", + Strategy::AutoRewire => "[DANGER] Auto-rewire client configs", + }; + let mux_dir = crate::mux_gen::default_mux_dir(); + let socket_dir = crate::mux_gen::default_socket_dir(&mux_dir); + let selected_services: Vec<&str> = app .services .iter() .filter(|s| s.selected) .map(|s| s.name.as_str()) .collect(); - let selected_clients: Vec<&str> = app - .clients - .iter() - .filter(|c| c.selected) - .map(|c| c.kind.as_label()) - .collect(); - - let mut lines: Vec = vec![ + let mut left = vec![ Line::from(Span::styled( - "Configuration Saved!", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - "Now verify the configuration works:", + "About to:", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from("1. Go to your MCP client application"), - Line::from("2. Check if the MCP servers are working"), - Line::from("3. Return here and confirm the result"), - Line::from(""), - Line::from(Span::styled( - format!("Configured Servers ({})", selected_servers.len()), - Style::default().fg(Color::Cyan), - )), + Line::from(format!(" Strategy : {strategy_label}")), ]; - for name in &selected_servers { - lines.push(Line::from(format!(" [x] {}", name))); + match app.strategy { + Strategy::Unified => { + left.push(Line::from(format!( + " Outputs : {}/config.toml", + mux_dir.display() + ))); + left.push(Line::from(format!( + " {}/mcp.json", + mux_dir.display() + ))); + left.push(Line::from(format!( + " {}/mcp.toml", + mux_dir.display() + ))); + } + Strategy::PerClient => { + left.push(Line::from(format!( + " Outputs : {}/config.toml (daemon truth)", + mux_dir.display() + ))); + // Predict per-client filenames from the selected STEP 2 services, + // matching what persist::selected_scans will actually write. + for kind in selected_per_client_output_kinds(app) { + let ext = match kind { + HostKind::Codex => "toml", + _ => "json", + }; + left.push(Line::from(format!( + " {}/{}.{}", + mux_dir.display(), + kind.as_label(), + ext + ))); + } + } + Strategy::AutoRewire => { + left.push(Line::from("")); + left.push(Line::from(Span::styled( + " Will rewrite (with .bak per file):", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + for src in app.sources.iter().filter(|s| { + s.selected + && matches!(s.status, SourceStatus::Ok { .. }) + && s.host_file.eligible_for_danger + }) { + left.push(Line::from(format!( + " β€’ {}", + src.host_file.path.display() + ))); + } + let ineligible: Vec<&_> = app + .sources + .iter() + .filter(|s| { + s.selected + && matches!(s.status, SourceStatus::Ok { .. }) + && !s.host_file.eligible_for_danger + }) + .collect(); + if !ineligible.is_empty() { + left.push(Line::from("")); + left.push(Line::from(Span::styled( + " Skipped (no strict-config flag for danger flow):", + Style::default().fg(Color::DarkGray), + ))); + for src in ineligible { + left.push(Line::from(format!( + " Β· {} ({})", + src.host_file.path.display(), + src.host_file.kind.display_name(), + ))); + } + } + } } - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - format!("Rewired Clients ({})", selected_clients.len()), - Style::default().fg(Color::Cyan), + left.push(Line::from("")); + left.push(Line::from(format!(" Sockets : {}", socket_dir.display()))); + left.push(Line::from(format!( + " Servers : {} selected", + selected_services.len() ))); - - for name in &selected_clients { - lines.push(Line::from(format!(" [x] {}", name))); + if app.dry_run { + left.push(Line::from("")); + left.push(Line::from(Span::styled( + " DRY-RUN: no files will be modified.", + Style::default().fg(Color::Yellow), + ))); } - let info = Paragraph::new(lines) + let body = Paragraph::new(left) .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title("STEP 4: Health Check"), + .border_style(Style::default().fg(Color::Cyan)) + .title("Summary"), ) .wrap(Wrap { trim: true }); + f.render_widget(body, columns[0]); - f.render_widget(info, area); -} - -pub fn draw_health_check_options(f: &mut Frame, app: &AppState, area: Rect) { - let border_style = Style::default().fg(Color::Cyan); - - let choices = [ - ( - HealthCheckChoice::Ok, - "OK", - "Configuration verified - exit wizard", - ), - ( - HealthCheckChoice::TryAgain, - "Try Again", - "Re-run detection and reconfigure", - ), + // Right: action chooser. + let actions = [ + (SummaryAction::Confirm, "Confirm", Color::Green), + (SummaryAction::Back, "Back", Color::Cyan), + (SummaryAction::Cancel, "Cancel", Color::Red), ]; - - let mut lines: Vec = vec![ + let mut right = vec![ Line::from(Span::styled( - "Verification", + "Choose action", Style::default().add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from("Did the configuration work correctly?"), - Line::from(""), - Line::from("Use Up/Down to select, Enter to confirm:"), - Line::from(""), ]; - - for (choice, label, description) in choices { - let is_selected = choice == app.health_choice; - let prefix = if is_selected { "β–Ά " } else { " " }; + for (action, label, color) in actions { + let is_selected = action == app.summary_action; + let marker = if is_selected { "β–Ά" } else { " " }; let style = if is_selected { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) + Style::default().fg(color).add_modifier(Modifier::BOLD) } else { - Style::default() + Style::default().fg(color) }; - - lines.push(Line::from(vec![ - Span::styled(prefix, style), - Span::styled(format!("[{}]", label), style), - Span::raw(" - "), - Span::styled(description, Style::default().fg(Color::DarkGray)), + right.push(Line::from(vec![ + Span::raw(format!(" {marker} ")), + Span::styled(label.to_string(), style), ])); } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Tip: Keep this terminal open while testing", + right.push(Line::from("")); + right.push(Line::from(Span::styled( + "Up/Down: choose, Enter: do it", Style::default().fg(Color::DarkGray), ))); - let options = Paragraph::new(lines) + let panel = Paragraph::new(right) .block( Block::default() .borders(Borders::ALL) - .border_style(border_style) - .title("Actions"), + .border_style(Style::default().fg(Color::DarkGray)) + .title("Action"), ) + .alignment(Alignment::Left) .wrap(Wrap { trim: true }); - - f.render_widget(options, area); + f.render_widget(panel, columns[1]); } // ───────────────────────────────────────────────────────────────────────────── -// Confirm dialog (overlay) +// STEP 5: Result + tray prompt // ───────────────────────────────────────────────────────────────────────────── -pub fn draw_confirm_dialog(f: &mut Frame, app: &AppState) { - let area = f.area(); - let dialog_width = 40; - let dialog_height = 7; - let x = (area.width.saturating_sub(dialog_width)) / 2; - let y = (area.height.saturating_sub(dialog_height)) / 2; - let dialog_area = Rect::new(x, y, dialog_width, dialog_height); - - // Clear the background - f.render_widget(Clear, dialog_area); +fn draw_step5_result(f: &mut Frame, app: &AppState, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(area); - let choices = [ - (ConfirmChoice::SaveAll, "SAVE ALL"), - (ConfirmChoice::SaveMuxOnly, "MUX ONLY"), - (ConfirmChoice::CopyToClipboard, "CLIPBOARD"), - (ConfirmChoice::Back, "BACK"), - (ConfirmChoice::Exit, "EXIT"), - ]; + let mut left = vec![Line::from(Span::styled( + "Result", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ))]; + left.push(Line::from("")); + if let Some(result) = &app.strategy_result { + for line in result.lines() { + left.push(Line::from(line.to_string())); + } + } else { + left.push(Line::from(Span::styled( + "(no result captured β€” see status bar)", + Style::default().fg(Color::DarkGray), + ))); + } - let choice_spans: Vec = choices - .iter() - .map(|(choice, label)| { - if *choice == app.confirm_choice { - Span::styled( - format!(" [{label}] "), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled(format!(" {label} "), Style::default().fg(Color::White)) - } - }) - .collect(); + let body = Paragraph::new(left) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title("Result"), + ) + .wrap(Wrap { trim: true }); + f.render_widget(body, columns[0]); - let content = vec![ + // Right: tray daemon prompt. + let actions = [ + (TrayChoice::StartNow, "Start tray daemon now", Color::Green), + (TrayChoice::No, "No, exit", Color::DarkGray), + ]; + let mut right = vec![ + Line::from(Span::styled( + "Tray daemon", + Style::default().add_modifier(Modifier::BOLD), + )), Line::from(""), - Line::from("Save configuration?"), + Line::from("Run a multi-service tray monitor"), + Line::from("for the sockets you just configured?"), Line::from(""), - Line::from(choice_spans), ]; + for (action, label, color) in actions { + let is_selected = action == app.tray_choice; + let marker = if is_selected { "β–Ά" } else { " " }; + let style = if is_selected { + Style::default().fg(color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(color) + }; + right.push(Line::from(vec![ + Span::raw(format!(" {marker} ")), + Span::styled(label.to_string(), style), + ])); + } + right.push(Line::from("")); + right.push(Line::from(Span::styled( + "Up/Down: choose, Enter: confirm", + Style::default().fg(Color::DarkGray), + ))); - let dialog = Paragraph::new(content) + let panel = Paragraph::new(right) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title("Confirm"), + .border_style(Style::default().fg(Color::DarkGray)) + .title("Action"), ) - .alignment(ratatui::layout::Alignment::Center); + .wrap(Wrap { trim: true }); + f.render_widget(panel, columns[1]); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── - f.render_widget(dialog, dialog_area); +fn kind_color(kind: HostKind) -> Color { + kind_color_value(kind) +} + +fn kind_color_value(kind: HostKind) -> Color { + match kind { + HostKind::Claude | HostKind::ClaudeDesktop => Color::Yellow, + HostKind::Codex => Color::Blue, + HostKind::Junie => Color::Green, + HostKind::Gemini => Color::Red, + HostKind::Cursor => Color::Magenta, + HostKind::VSCode => Color::Cyan, + HostKind::JetBrains => Color::Green, + HostKind::Custom | HostKind::Unknown => Color::DarkGray, + } +} + +fn selected_per_client_output_kinds(app: &AppState) -> Vec { + let mut kinds: Vec = app + .services + .iter() + .filter(|svc| svc.selected) + .filter_map(|svc| match &svc.source { + ServiceSource::Client { kind, .. } => Some(*kind), + ServiceSource::Default { .. } => None, + ServiceSource::DetectedRunning => None, + }) + .collect(); + kinds.sort_by_key(|k| k.as_label()); + kinds.dedup(); + kinds +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ServerConfig; + use crate::scan::{Confidence, ConfigSchema, HostFile, HostFormat}; + use crate::wizard::types::{ + CustomPathInput, HealthStatus, ServiceEntry, SourceEntry, Strategy, SummaryAction, + TrayChoice, + }; + use std::path::PathBuf; + + fn host(kind: HostKind, path: &str) -> HostFile { + HostFile { + kind, + path: PathBuf::from(path), + format: HostFormat::Json, + schema: ConfigSchema::McpServersJson, + confidence: Confidence::High, + writable: true, + eligible_for_danger: true, + } + } + + fn service(name: &str, source: ServiceSource, selected: bool) -> ServiceEntry { + ServiceEntry { + name: name.into(), + config: ServerConfig { + socket: None, + cmd: Some("npx".into()), + args: Some(vec!["@modelcontextprotocol/server-memory".into()]), + cwd: None, + env: None, + max_active_clients: Some(5), + tray: Some(false), + service_name: Some(name.into()), + log_level: Some("info".into()), + lazy_start: Some(false), + max_request_bytes: Some(1_048_576), + request_timeout_ms: Some(30_000), + restart_backoff_ms: Some(1_000), + restart_backoff_max_ms: Some(30_000), + max_restarts: Some(5), + status_file: None, + heartbeat_interval_ms: Some(30_000), + heartbeat_timeout_ms: Some(30_000), + heartbeat_max_failures: Some(3), + heartbeat_enabled: Some(true), + }, + health: HealthStatus::Unknown, + source, + pid: None, + selected, + } + } + + fn app_for_summary() -> AppState { + AppState { + wizard_step: WizardStep::SummaryConfirm, + config_path: PathBuf::from("/tmp/mux.toml"), + sources: vec![ + SourceEntry { + host_file: host(HostKind::Claude, "/tmp/claude.json"), + status: SourceStatus::Ok { servers_found: 1 }, + selected: true, + }, + SourceEntry { + host_file: host(HostKind::Codex, "/tmp/codex.toml"), + status: SourceStatus::Ok { servers_found: 1 }, + selected: true, + }, + ], + selected_source: 0, + custom_path: CustomPathInput::default(), + services: vec![ + service( + "memory", + ServiceSource::Client { + kind: HostKind::Claude, + path: PathBuf::from("/tmp/claude.json"), + }, + true, + ), + service( + "brave", + ServiceSource::Client { + kind: HostKind::Codex, + path: PathBuf::from("/tmp/codex.toml"), + }, + false, + ), + service("running-only", ServiceSource::DetectedRunning, true), + ], + selected_service: 0, + strategy: Strategy::PerClient, + summary_action: SummaryAction::Confirm, + tray_choice: TrayChoice::No, + message: String::new(), + dry_run: true, + pending_action: None, + strategy_result: None, + } + } + + #[test] + fn per_client_summary_kinds_follow_selected_services() { + let app = app_for_summary(); + + assert_eq!( + selected_per_client_output_kinds(&app), + vec![HostKind::Claude] + ); + } } diff --git a/tools/githooks/pre-commit b/tools/githooks/pre-commit index 7d1c3fa..dd5e16b 100755 --- a/tools/githooks/pre-commit +++ b/tools/githooks/pre-commit @@ -1,50 +1,26 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail -REPO_ROOT=$(git rev-parse --show-toplevel) -cd "$REPO_ROOT" +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" -echo "πŸ” [Pre-commit] Running quality gate..." +echo "[pre-commit] cargo fmt --check" +cargo fmt -- --check -echo "" -echo "[1/4] πŸ¦€ cargo fmt --check..." -if cargo fmt --check; then - echo " βœ“ formatting OK" -else - echo "❌ Run 'cargo fmt' before committing." - exit 1 -fi +echo "[pre-commit] cargo clippy --all-targets --all-features -D warnings" +cargo clippy --all-targets --all-features -- -D warnings -echo "" -echo "[2/4] πŸ¦€ cargo clippy..." -if cargo clippy --all-targets --all-features --quiet -- -D warnings; then - echo " βœ“ clippy OK" -else - echo "❌ Clippy errors. Fix before committing." - exit 1 -fi - -echo "" -echo "[3/4] πŸ§ͺ cargo test..." -if cargo test --quiet; then - echo " βœ“ tests OK" -else - echo "❌ Tests failed!" - exit 1 -fi +echo "[pre-commit] cargo test" +cargo test --all-targets -echo "" -echo "[4/4] πŸ” semgrep scan..." if command -v semgrep >/dev/null 2>&1; then - if semgrep scan --config auto --quiet --error 2>/dev/null; then - echo " βœ“ semgrep OK" - else - echo "❌ Semgrep found issues. Fix before committing." - exit 1 - fi + echo "[pre-commit] semgrep (.semgrep.yaml)" + semgrep \ + --config .semgrep.yaml \ + --error --metrics=off \ + --exclude target --exclude lancedb --exclude .fastembed_cache else - echo " ⚠️ semgrep not installed (install via 'pipx install semgrep')" + echo "[pre-commit] semgrep not installed; skipping (install via 'pipx install semgrep')." fi -echo "" -echo "βœ… Quality gate passed β€” commit allowed." +echo "[pre-commit] done" diff --git a/tools/install-githooks.sh b/tools/install-githooks.sh index c8e68ba..055fc38 100755 --- a/tools/install-githooks.sh +++ b/tools/install-githooks.sh @@ -3,14 +3,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOOKS_DIR="$ROOT/.git/hooks" -SRC="$ROOT/tools/githooks/pre-commit" +PRE_COMMIT_SRC="$ROOT/tools/githooks/pre-commit" +PRE_PUSH_SRC="$ROOT/tools/githooks/pre-push" if [[ ! -d "$HOOKS_DIR" ]]; then echo "No .git/hooks directory found. Are you in a git repo?" >&2 exit 1 fi -chmod +x "$SRC" -ln -sf "$SRC" "$HOOKS_DIR/pre-commit" +chmod +x "$PRE_COMMIT_SRC" "$PRE_PUSH_SRC" +ln -sf "$PRE_COMMIT_SRC" "$HOOKS_DIR/pre-commit" +ln -sf "$PRE_PUSH_SRC" "$HOOKS_DIR/pre-push" echo "Installed pre-commit hook -> $HOOKS_DIR/pre-commit" +echo "Installed pre-push hook -> $HOOKS_DIR/pre-push" diff --git a/tools/install.sh b/tools/install.sh index 7631a8b..b2edd2a 100644 --- a/tools/install.sh +++ b/tools/install.sh @@ -2,11 +2,11 @@ set -euo pipefail umask 022 -# rmcp-mux install script +# rust-mux install script # Usage: -# curl -fsSL https://raw.githubusercontent.com/Loctree/rmcp-mux/main/tools/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/Loctree/rust-mux/main/tools/install.sh | sh # Env overrides: -# INSTALL_DIR where to place the runnable `rmcp-mux` wrapper (default: $HOME/.local/bin) +# INSTALL_DIR where to place the runnable `rust-mux` wrapper (default: $HOME/.local/bin) # CARGO_HOME override cargo home (default: ~/.cargo) # MUX_REF branch/tag/commit to install (default: main) # MUX_NO_LOCK set to 1 to skip --locked @@ -14,33 +14,33 @@ umask 022 INSTALL_DIR=${INSTALL_DIR:-"$HOME/.local/bin"} CARGO_HOME=${CARGO_HOME:-"$HOME/.cargo"} CARGO_BIN="$CARGO_HOME/bin" -REPO_URL="https://github.com/LibraxisAI/rmcp-mux" +REPO_URL="https://github.com/Loctree/rust-mux" # Allow pinning a branch/tag/commit; defaults to main. MUX_REF=${MUX_REF:-"main"} -info() { printf "[rmcp-mux] %s\n" "$*"; } -warn() { printf "[rmcp-mux][warn] %s\n" "$*" >&2; } +info() { printf "[rust-mux] %s\n" "$*"; } +warn() { printf "[rust-mux][warn] %s\n" "$*" >&2; } command -v cargo >/dev/null 2>&1 || { warn "cargo not found. Install Rust (e.g. https://rustup.rs) then re-run."; exit 1; } -info "Installing rmcp-mux from $REPO_URL (ref: $MUX_REF)" +info "Installing rust-mux from $REPO_URL (ref: $MUX_REF)" # --locked keeps dependency resolution reproducible; override with MUX_NO_LOCK=1 if needed. lock_flag="--locked" [ "${MUX_NO_LOCK:-0}" = "1" ] && lock_flag="" # --rev accepts branches, tags, or commits. -cargo install --git "$REPO_URL" --rev "$MUX_REF" $lock_flag --force rmcp-mux >/dev/null +cargo install --git "$REPO_URL" --rev "$MUX_REF" $lock_flag --force rust-mux >/dev/null -installed_bin="$CARGO_BIN/rmcp-mux" +installed_bin="$CARGO_BIN/rust-mux" if [[ ! -x $installed_bin ]]; then - warn "rmcp-mux binary not found at $installed_bin after install"; + warn "rust-mux binary not found at $installed_bin after install"; exit 1; fi mkdir -p "$INSTALL_DIR" -wrapper="$INSTALL_DIR/rmcp-mux" +wrapper="$INSTALL_DIR/rust-mux" cat >"$wrapper" < Label - com.loctree.mcp-mux.general-memory + com.libraxis.mcp-mux.general-memory ProgramArguments - /Users/YOUR_USER/.cargo/bin/rmcp-mux + /Users/YOUR_USER/.cargo/bin/rust-mux --config /Users/YOUR_USER/.codex/mcp.json --service @@ -18,7 +18,7 @@ RunAtLoad KeepAlive - StandardOutPath/tmp/rmcp-mux.general-memory.out - StandardErrorPath/tmp/rmcp-mux.general-memory.err + StandardOutPath/tmp/rust-mux.general-memory.out + StandardErrorPath/tmp/rust-mux.general-memory.err