From 0f3296fd37abd8e1033b5780f3b3bd7a8de4aa19 Mon Sep 17 00:00:00 2001 From: jmagar <38927646+jmagar@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:17:18 -0400 Subject: [PATCH 1/2] chore(main): release 1.0.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 11 +++++++++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- docs/generated/openapi.json | 2 +- packages/synapse-rmcp/package.json | 2 +- server.json | 8 ++++---- xtask/Cargo.toml | 2 +- 8 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bf5f1fb..37fcefa 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.2" + ".": "1.0.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eda237..6fa4fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Format: ## [X.Y.Z] — YYYY-MM-DD Use Added / Changed / Deprecated / Removed / Fixed / Security headers. --> +## [1.0.0](https://github.com/dinglebear-ai/synapse/compare/v0.6.2...v1.0.0) (2026-07-29) + + +### ⚠ BREAKING CHANGES + +* complete Synapse identity cutover ([#80](https://github.com/dinglebear-ai/synapse/issues/80)) + +### Added + +* complete Synapse identity cutover ([#80](https://github.com/dinglebear-ai/synapse/issues/80)) ([cf1f3f9](https://github.com/dinglebear-ai/synapse/commit/cf1f3f9ef5925b28f5a5a1ca2d8da5af28815cc2)) + ## [0.6.2](https://github.com/dinglebear-ai/synapse/compare/v0.6.1...v0.6.2) (2026-07-28) diff --git a/Cargo.lock b/Cargo.lock index 2d5c85d..7c7c8a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3018,7 +3018,7 @@ dependencies = [ [[package]] name = "synapse" -version = "0.6.2" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -4122,7 +4122,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xtask" -version = "0.6.2" +version = "1.0.0" dependencies = [ "anyhow", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index be5da03..524e567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ resolver = "2" [package] name = "synapse" -version = "0.6.2" +version = "1.0.0" edition = "2024" rust-version = "1.90" categories = ["command-line-utilities", "development-tools"] diff --git a/docs/generated/openapi.json b/docs/generated/openapi.json index 36c8518..669b77d 100644 --- a/docs/generated/openapi.json +++ b/docs/generated/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Synapse REST API", - "version": "0.6.2", + "version": "1.0.0", "description": "Generated OpenAPI schema for Synapse's REST surface. Loopback deployments have no HTTP auth; non-loopback deployments require SYNAPSE_MCP_TOKEN or OAuth bearer JWTs. REST actions require their action-specific scopes when auth is mounted." }, "servers": [ diff --git a/packages/synapse-rmcp/package.json b/packages/synapse-rmcp/package.json index 4bf9d16..9d5e85a 100644 --- a/packages/synapse-rmcp/package.json +++ b/packages/synapse-rmcp/package.json @@ -1,6 +1,6 @@ { "name": "synapse-rmcp", - "version": "0.6.2", + "version": "1.0.0", "description": "Node launcher for the Synapse Rust MCP server and CLI for Docker, host, SSH, log, ZFS, and file workflows.", "license": "MIT", "homepage": "https://github.com/jmagar/synapse#readme", diff --git a/server.json b/server.json index b0acc42..41abc3e 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "ai.dinglebear/synapse-rmcp", "title": "Synapse RMCP", "description": "Rust MCP and CLI server for Docker, host inspection, SSH, logs, ZFS, and safe file operations.", - "version": "0.6.2", + "version": "1.0.0", "websiteUrl": "https://github.com/jmagar/synapse", "repository": { "url": "https://github.com/jmagar/synapse", @@ -25,7 +25,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "synapse-rmcp", - "version": "0.6.2", + "version": "1.0.0", "runtimeHint": "npx", "packageArguments": [ { @@ -105,11 +105,11 @@ "namespace": "ai.dinglebear", "dnsDomain": "dinglebear.ai", "distribution": { - "npm": "synapse-rmcp@0.6.2", + "npm": "synapse-rmcp@1.0.0", "nodePackage": "synapse-rmcp" }, "buildInfo": { - "version": "0.6.2", + "version": "1.0.0", "repository": "https://github.com/jmagar/synapse" } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 4c89ab5..634c13c 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,7 +13,7 @@ [package] name = "xtask" -version = "0.6.2" +version = "1.0.0" edition = "2021" # Exclude from crates.io — this is a private automation crate. publish = false From bf5cd79b73101aa6f13adaca00a52538ef11f105 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 18:54:03 -0400 Subject: [PATCH 2/2] chore: refresh release branch onto current main --- .github/actionlint.yaml | 15 + .github/actions/setup-rust-kache/action.yml | 233 +++++++++ .github/workflows/ci.yml | 89 ++-- .github/workflows/codeql.yml | 4 +- .github/workflows/dependabot-auto-merge.yml | 2 +- .github/workflows/docker-publish.yml | 28 +- .github/workflows/msrv.yml | 13 +- .github/workflows/release-please.yml | 3 +- .github/workflows/release.yml | 32 +- .github/workflows/repository-contract.yml | 40 ++ CLAUDE.md | 12 +- Cargo.lock | 35 +- Cargo.toml | 80 ++- README.md | 19 +- config/Dockerfile | 10 +- docker-compose.prod.yml | 2 +- docs/AGENTS-FIRST.md | 6 + docs/API.md | 6 + docs/ARCHITECTURE.md | 6 + docs/AUTH.md | 6 + docs/CI.md | 6 + docs/CLI_DESTRUCTIVE_SMOKE.md | 6 + docs/CONFIG.md | 6 + docs/DEPLOYMENT.md | 6 + docs/DOCKER.md | 6 + docs/DOCS.md | 6 + docs/ENV.md | 6 + docs/JUSTFILE.md | 6 + docs/MCP-REGISTRY-PUBLISH-GUIDE.md | 12 +- docs/MCPORTER.md | 6 + docs/MCP_SCHEMA.md | 6 + docs/OBSERVABILITY.md | 6 + docs/PATTERNS.md | 6 + docs/PHILOSOPHY.md | 6 + docs/PLUGINS.md | 6 + docs/PRE-COMMIT.md | 6 + docs/QUICKSTART.md | 6 + docs/RUST.md | 6 + docs/SCRIPTS.md | 8 +- docs/SECURITY.md | 6 + docs/SYSTEMD.md | 6 + docs/TESTING.md | 6 + docs/WEB.md | 6 + docs/XTASKS.md | 6 + docs/specs/scaffold-intent-handoff.md | 6 + install.sh | 4 +- packages/synapse-rmcp/LICENSE | 21 + packages/synapse-rmcp/README.md | 150 +++--- packages/synapse-rmcp/lib/platform.js | 2 +- packages/synapse-rmcp/package.json | 27 +- .../synapse-rmcp/scripts/check-package.js | 469 ++++++++++++++++++ packages/synapse-rmcp/scripts/sync-readme.js | 26 + packages/synapse-rmcp/test/platform.test.js | 2 +- plugins/synapse/.claude-plugin/plugin.json | 6 +- plugins/synapse/.codex-plugin/plugin.json | 8 +- plugins/synapse/.mcp.json | 11 + plugins/synapse/gemini-extension.json | 4 +- plugins/synapse/monitors/monitors.json | 4 +- release-please-config.json | 6 +- rust-toolchain.toml | 10 +- scripts/README.md | 2 +- scripts/check-schema-docs.py | 6 + scripts/kache-gate-selftest.sh | 93 ++++ scripts/kache-gate.sh | 167 +++++++ scripts/pre-release-check.sh | 4 +- server.json | 22 +- src/app.rs | 2 +- src/docker_client.rs | 2 +- src/fanout.rs | 2 +- src/flux_service/container_lifecycle.rs | 2 +- src/flux_service/container_read.rs | 2 +- src/flux_service/docker.rs | 2 +- src/flux_service/host.rs | 2 +- src/formatters/scout.rs | 7 +- src/logging.rs | 2 +- src/mcp/rmcp_server.rs | 166 ++++--- src/mcp/transport.rs | 2 +- src/ssh.rs | 4 +- src/ssh/pool.rs | 2 +- tests/plugin_contract.rs | 4 +- xtask/Cargo.toml | 11 +- xtask/src/main.rs | 4 +- xtask/src/patterns.rs | 2 +- 83 files changed, 1701 insertions(+), 360 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/setup-rust-kache/action.yml create mode 100644 .github/workflows/repository-contract.yml create mode 100644 packages/synapse-rmcp/LICENSE create mode 100644 packages/synapse-rmcp/scripts/check-package.js create mode 100644 packages/synapse-rmcp/scripts/sync-readme.js create mode 100644 plugins/synapse/.mcp.json create mode 100755 scripts/kache-gate-selftest.sh create mode 100755 scripts/kache-gate.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9197dbe --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,15 @@ +self-hosted-runner: + labels: + - rmcp-template + - tootie + - unraid + - dookie + - ci-pool-rust + - ci-pool-python + - ci-pool-typescript + - ci-pool-go + - ci-pool-ops + - ci-pool-jvm + - ci-pool-system + +config-variables: null diff --git a/.github/actions/setup-rust-kache/action.yml b/.github/actions/setup-rust-kache/action.yml new file mode 100644 index 0000000..0c34cdb --- /dev/null +++ b/.github/actions/setup-rust-kache/action.yml @@ -0,0 +1,233 @@ +name: Setup Rust with kache +description: Install a Rust toolchain plus a pinned kache, point it at the shared filesystem remote, start the daemon, and print cache evidence. + +inputs: + toolchain: + description: Rust toolchain to install. + required: false + default: stable + components: + description: Comma-separated Rust components to install. + required: false + default: "" + targets: + description: Comma-separated Rust targets to install. + required: false + default: "" + enable-cache: + description: >- + "true" (default) wires kache as RUSTC_WRAPPER and starts the daemon. + "false" leaves cargo bare — used by the GitHub-hosted native Windows job, + which cannot reach the shared remote. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Install Linux build prerequisites + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + need_install=false + command -v cc >/dev/null 2>&1 || need_install=true + command -v pkg-config >/dev/null 2>&1 || need_install=true + pkg-config --exists openssl 2>/dev/null || need_install=true + pkg-config --exists dbus-1 2>/dev/null || need_install=true + # soma-tauri-shell (crates/shared/tauri-shell) pulls in Tauri's Linux + # desktop stack: GTK3 (tray icon/windowing), WebKitGTK (webview), + # ayatana-appindicator (tray icon), rsvg (icon rendering), and xdotool + # (global shortcuts). + pkg-config --exists gtk+-3.0 2>/dev/null || need_install=true + pkg-config --exists webkit2gtk-4.1 2>/dev/null || need_install=true + pkg-config --exists ayatana-appindicator3-0.1 2>/dev/null || need_install=true + pkg-config --exists librsvg-2.0 2>/dev/null || need_install=true + pkg-config --exists xdo 2>/dev/null || need_install=true + if [ "$need_install" = false ]; then + exit 0 + fi + packages="build-essential pkg-config libssl-dev libdbus-1-dev libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev" + if command -v apt-get >/dev/null 2>&1; then + if [ "$(id -u)" = "0" ]; then + apt-get update + # shellcheck disable=SC2086 + apt-get install -y $packages + elif command -v sudo >/dev/null 2>&1; then + sudo apt-get update + # shellcheck disable=SC2086 + sudo apt-get install -y $packages + else + echo "::error::apt-get is available but the runner is not root and sudo is missing." + exit 1 + fi + exit 0 + fi + echo "::error::No C compiler found and no supported package manager is available." + exit 1 + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: ${{ inputs.toolchain }} + components: ${{ inputs.components }} + targets: ${{ inputs.targets }} + + # Pinned exactly. The filesystem remote is new in 0.12.0 (PR #593) and is + # SILENTLY IGNORED by 0.11.0 — it reports "Remote: not configured" with no + # error. Floating this version would degrade the shared cache without any + # failure signal. Bump deliberately, never via tag drift. + - name: Install kache + if: inputs.enable-cache == 'true' && runner.os == 'Linux' + shell: bash + env: + KACHE_VERSION: "0.12.0" + run: | + set -euo pipefail + # The farm image bakes an exactly-pinned kache at /usr/local/bin/kache + # (and runs the daemon for the container lifetime). Download only when + # the baked binary is absent or the wrong version — e.g. a runner + # image that predates the bake, or a non-farm self-hosted box. + if [ -x /usr/local/bin/kache ] && /usr/local/bin/kache --version | grep -q " ${KACHE_VERSION}\$"; then + echo "kache ${KACHE_VERSION} baked into the runner image — skipping download" + exit 0 + fi + target=x86_64-unknown-linux-musl + url="https://github.com/kunobi-ninja/kache/releases/download/v${KACHE_VERSION}/kache-${target}.tar.gz" + tmp="$(mktemp -d)" + # Keep the UPSTREAM filename: the .sha256 manifest names + # "kache-.tar.gz", and `sha256sum -c` verifies exactly the file + # the manifest names. Saving under any other name makes the check fail + # on a missing file — an error that reads like a mismatch but has + # verified nothing. Do not weaken this into `|| true` or drop + # `--status`-strictness: that would turn the supply-chain check into a + # silent no-op. + curl -fsSL --retry 3 -o "$tmp/kache-${target}.tar.gz" "$url" + curl -fsSL --retry 3 -o "$tmp/kache-${target}.tar.gz.sha256" "${url}.sha256" + ( cd "$tmp" && sha256sum -c --status "kache-${target}.tar.gz.sha256" ) \ + || { echo "::error::kache checksum verification failed for kache-${target}.tar.gz"; exit 1; } + tar -xzf "$tmp/kache-${target}.tar.gz" -C "$tmp" + install -Dm755 "$(find "$tmp" -type f -name kache | head -n1)" "$HOME/.local/bin/kache" + rm -rf "$tmp" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/kache" --version + + - name: Configure kache + if: inputs.enable-cache == 'true' && runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + # ONE local store per runner container at /_work/.kache — /_work is + # this runner's PRIVATE persistent host mount, so the store survives + # jobs and recycles without ever crossing an OS boundary (the + # forbidden case is cross-CONTAINER sharing, not cross-repo: a single + # store dedups every repo on this runner and is the store the + # container-lifetime daemon serves). Fall back to the old + # workspace-parent path only where /_work does not exist. + if [ -d /_work ] && [ -w /_work ]; then + cache_dir=/_work/.kache + else + cache_dir="$(cd "$GITHUB_WORKSPACE/.." && pwd)/.kache" + fi + mkdir -p "$cache_dir" + + # The REMOTE is the shared surface: one directory on tootie's raidz1 + # cache pool, bind-mounted into every runner. + # + # A missing mount degrades to local-only rather than failing the job: + # runners are rolled out one image build at a time, so hard-failing here + # would break every runner that has not been recreated yet. The + # DEGRADATION IS NOT SILENT — it is annotated here and enforced by + # scripts/kache-gate.sh, which fails the job when + # KACHE_GATE_REQUIRE_REMOTE=1 and no remote hit occurred. Setup degrades; + # the gate enforces. Do not move enforcement into this step. + remote_dir=/home/runner/kache-remote + mkdir -p "$HOME/.config/kache" + # The farm image bakes a config carrying local_store + the remote; + # the container daemon read it at boot. Never overwrite it — a + # per-job rewrite would race the daemon's view of the store. + if grep -qs "^local_store" "$HOME/.config/kache/config.toml"; then + echo "baked kache config present — leaving it alone" + elif [ -w "$remote_dir" ]; then + cat > "$HOME/.config/kache/config.toml" < "$HOME/.config/kache/config.toml" <> "$GITHUB_ENV" + + - name: Ensure kache daemon + if: inputs.enable-cache == 'true' && runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + # The farm image supervises a container-lifetime daemon (started at + # boot, outside job process groups, so the post-job orphan reaper + # cannot kill it and the upload queue survives job end). This step is + # therefore normally a no-op verification; the start covers non-farm + # runners. The daemon is the ONLY path that uploads dependency + # artifacts and the ONLY path that performs remote lookups — and kache + # is fail-open, so wrappers that race daemon initialization silently + # skip the remote and compile. Wait until it answers. + kache daemon status >/dev/null 2>&1 || kache daemon start + for _ in $(seq 1 30); do + kache daemon status >/dev/null 2>&1 && break + sleep 1 + done + kache daemon status >/dev/null 2>&1 || { echo "::error::kache daemon is not answering"; exit 1; } + kache daemon + + - name: Print cache evidence + if: inputs.enable-cache == 'true' && runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + echo "kache $(kache --version)" + echo "wrapper ${RUSTC_WRAPPER:-}" + echo "store ${KACHE_CACHE_DIR:-}" + kache doctor || true + kache stats || true + + # Snapshot the cumulative counters so scripts/kache-gate.sh can diff this + # build's contribution out of them. Required because `kache report --since` + # does NOT bound the event window in 0.12.0 — measured on soma 2026-07-29, + # `--since 1m` and `--since 24h` returned counts differing by 2 out of + # 6,656, and a 1-minute query reported a 32-minute timeline. Without a + # baseline, `errors > 0` would go permanently red after one historical + # store failure and `remote_hits > 0` would pass forever after one hit. + - name: Baseline kache counters for the gate + if: inputs.enable-cache == 'true' && runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + echo "KACHE_GATE_BASELINE=${RUNNER_TEMP}/kache-gate-baseline.json" >> "$GITHUB_ENV" + KACHE_GATE_BASELINE="${RUNNER_TEMP}/kache-gate-baseline.json" \ + KACHE_GATE_ROOT="$GITHUB_WORKSPACE" \ + "$GITHUB_WORKSPACE/scripts/kache-gate.sh" --baseline diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05ccd94..c60cf53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,21 +31,20 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 CARGO_INCREMENTAL: "0" - SOLDR_CACHE_DIR: ${{ github.workspace }}/../.soldr-cache jobs: # ── fmt: cargo fmt --check ────────────────────────────────────────────────── fmt: name: Format - runs-on: [self-hosted, unraid] + runs-on: ci-pool-rust timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 components: rustfmt - name: cargo fmt --check @@ -54,15 +53,15 @@ jobs: # ── clippy: zero warnings ──────────────────────────────────────────────────── clippy: name: Clippy - runs-on: [self-hosted, unraid] + runs-on: ci-pool-rust timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 components: clippy - name: cargo clippy -- -D warnings @@ -71,15 +70,15 @@ jobs: # ── test: cargo nextest run --profile ci ───────────────────────────────────── test: name: Test - runs-on: [self-hosted, unraid] + runs-on: ci-pool-rust timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 - name: Install cargo-nextest uses: taiki-e/install-action@95cc2373c3f00275e315c306e56fef1c7a8854c0 # cargo-nextest @@ -89,15 +88,15 @@ jobs: coverage: name: Coverage (65% lines) - runs-on: [self-hosted, unraid] + runs-on: ci-pool-rust timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 - name: Install cargo-llvm-cov uses: taiki-e/install-action@95cc2373c3f00275e315c306e56fef1c7a8854c0 # cargo-llvm-cov @@ -112,15 +111,15 @@ jobs: live-integration: name: Live JSON-RPC + SSH - runs-on: [self-hosted, unraid] + runs-on: ci-pool-system timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 - name: Configure disposable localhost SSH fixture shell: bash @@ -180,10 +179,12 @@ jobs: env: SYNAPSE_HOME: ${{ runner.temp }}/synapse-home SYNAPSE_MCP_HOST: 127.0.0.1 - SYNAPSE_MCP_PORT: "40080" SYNAPSE_MCP_NO_AUTH: "true" run: | mkdir -p "$SYNAPSE_HOME" + mcp_port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + export SYNAPSE_MCP_PORT="$mcp_port" + export SYNAPSE_TEST_BASE_URL="http://127.0.0.1:$mcp_port" synapse_hosts_config="$(python3 -c 'import json, os; print(json.dumps([{"name": "ci-ssh", "host": "127.0.0.1", "protocol": "ssh", "sshUser": "synapse-ci", "sshKeyPath": os.path.join(os.environ["RUNNER_TEMP"], "id_ed25519"), "sshPort": int(os.environ["SYNAPSE_TEST_SSH_PORT"]), "scoutReadRoots": ["/tmp"]}]))')" export SYNAPSE_HOSTS_CONFIG="$synapse_hosts_config" cargo build --locked @@ -203,7 +204,7 @@ jobs: # ── web: pnpm install, lint, audit, static build ───────────────────────────── web: name: Web - runs-on: [self-hosted, unraid] + runs-on: ci-pool-typescript timeout-minutes: 20 defaults: run: @@ -240,7 +241,7 @@ jobs: # ── toml: taplo check ───────────────────────────────────────────────────────── toml: name: TOML Format - runs-on: [self-hosted, unraid] + runs-on: ci-pool-ops timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -262,17 +263,17 @@ jobs: # ── template: generated docs, plugin layout, coupled files ─────────────────── template: name: Template Contracts - runs-on: [self-hosted, unraid] + runs-on: ci-pool-ops timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 - name: Check PATTERNS.md contracts run: cargo xtask patterns @@ -313,33 +314,30 @@ jobs: # ── deny: dependency advisories, licenses, bans, sources ───────────────────── audit: name: Cargo Deny - runs-on: [self-hosted, unraid] + runs-on: ci-pool-rust timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 - - name: cargo deny check - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2 - with: - command: check - arguments: --all-features - - - name: Install cargo-deny for exception guard + - name: Install cargo-deny uses: taiki-e/install-action@95cc2373c3f00275e315c306e56fef1c7a8854c0 with: tool: cargo-deny + - name: cargo deny check + run: cargo deny --all-features check + - name: Enforce time-bounded yanked exceptions run: python3 scripts/check-yanked-exceptions.py npm: name: npm launcher - runs-on: [self-hosted, unraid] + runs-on: ci-pool-typescript steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 @@ -354,7 +352,7 @@ jobs: container: name: Container smoke - runs-on: [self-hosted, unraid] + runs-on: ci-pool-system timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -366,12 +364,15 @@ jobs: run: docker build -f config/Dockerfile -t synapse2:ci . - name: Smoke liveness run: | - docker run -d --rm --name synapse2-ci -p 127.0.0.1:40080:40080 \ + host_port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + container_name="synapse2-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + docker rm -f "$container_name" >/dev/null 2>&1 || true + docker run -d --rm --name "$container_name" -p "127.0.0.1:${host_port}:40080" \ -e SYNAPSE_MCP_HOST=0.0.0.0 \ -e SYNAPSE_MCP_TOKEN=ci-smoke-token-with-sufficient-length synapse2:ci - trap 'docker logs synapse2-ci; docker stop synapse2-ci >/dev/null' EXIT + trap 'docker logs "$container_name"; docker stop "$container_name" >/dev/null' EXIT for _attempt in {1..30}; do - curl -fsS http://127.0.0.1:40080/health && exit 0 + curl -fsS "http://127.0.0.1:${host_port}/health" && exit 0 sleep 1 done exit 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 041add6..cca5660 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,7 +10,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: [self-hosted, unraid] + runs-on: ci-pool-ops timeout-minutes: 20 permissions: security-events: write @@ -56,7 +56,7 @@ jobs: - name: Build if: matrix.language == 'rust' - run: cargo build --release + run: cargo build --locked - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index d14063f..2d38af8 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -11,7 +11,7 @@ permissions: jobs: auto-merge: name: Auto-merge patch/minor updates - runs-on: [self-hosted, unraid] + runs-on: ci-pool-ops if: github.actor == 'dependabot[bot]' steps: - name: Fetch Dependabot metadata diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 451fa70..5491532 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,32 +2,28 @@ # .github/workflows/docker-publish.yml — Build and publish Docker image # # Triggers: -# - push to main → builds and pushes :latest tag -# - push of v* tag → builds and pushes : tag + GitHub Release assets +# - published GitHub release → builds and pushes the immutable release image # -# Registry: ghcr.io/jmagar/synapse +# Registry: ghcr.io/dinglebear-ai/synapse # ============================================================================= name: Docker Publish on: - push: - branches: [main] - tags: ["v*"] + release: + types: [published] -# Prevent an older main build from finishing after a newer one and moving the -# mutable `latest` tag backwards. Tag builds use their own ref-specific group. concurrency: - group: docker-publish-${{ github.ref }} - cancel-in-progress: true + group: docker-publish-${{ github.event.release.tag_name }} + cancel-in-progress: false env: - IMAGE_NAME: ghcr.io/jmagar/synapse + IMAGE_NAME: ghcr.io/dinglebear-ai/synapse jobs: docker: name: Build & Push Docker image - runs-on: [self-hosted, unraid] + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -47,24 +43,18 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Determine the Docker tags to push: - # main branch → :latest - # v1.2.3 tag → :1.2.3 and :1.2 (latest is promoted by main only) + # Determine immutable Docker tags from the published release tag. - name: Docker metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=long - type=raw,value=latest,enable={{is_default_branch}} # Single-platform amd64 build and push. - # arm64 was dropped: emulated QEMU arm64 Rust builds exceeded the job - # timeout. Re-add via a native arm64 runner matrix if arm64 is needed. - name: Build exact image for scanning uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: diff --git a/.github/workflows/msrv.yml b/.github/workflows/msrv.yml index e28b88f..3c9a340 100644 --- a/.github/workflows/msrv.yml +++ b/.github/workflows/msrv.yml @@ -10,23 +10,22 @@ permissions: contents: read env: - # REQUIRED under a compile wrapper (soldr/zccache). + # REQUIRED under a compile wrapper (kache). CARGO_INCREMENTAL: "0" # Persistent per-runner zccache compile store. - SOLDR_CACHE_DIR: ${{ github.workspace }}/../.soldr-cache jobs: msrv: - name: Minimum Supported Rust Version (1.90) - runs-on: [self-hosted, unraid] + name: Minimum Supported Rust Version + runs-on: ci-pool-rust timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Install Rust 1.90 + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: "1.90.0" + toolchain: "1.97.1" - name: cargo check (MSRV) run: cargo check --all-targets --locked diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index aa32279..35fb967 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -17,7 +17,8 @@ concurrency: jobs: release-please: if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - runs-on: [self-hosted, unraid] + runs-on: ci-pool-ops + timeout-minutes: 15 permissions: contents: write pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c6140e..7378cf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,11 +14,11 @@ permissions: env: CARGO_INCREMENTAL: "0" - SOLDR_CACHE_DIR: ${{ github.workspace }}/../.soldr-cache jobs: release-meta: - runs-on: [self-hosted, unraid] + runs-on: ubuntu-24.04 + timeout-minutes: 60 outputs: tag_name: ${{ steps.meta.outputs.tag_name }} release_name: ${{ steps.meta.outputs.release_name }} @@ -68,16 +68,17 @@ jobs: artifact: synapse-linux-x86_64 archive: synapse-x86_64.tar.gz ext: "" - runs-on: [self-hosted, unraid] + runs-on: ubuntu-24.04 + timeout-minutes: 60 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release-meta.outputs.tag_name }} - - name: Install Rust + soldr - uses: ./.github/actions/setup-rust-soldr + - name: Install Rust and kache + uses: ./.github/actions/setup-rust-kache with: - toolchain: 1.90.0 + toolchain: 1.97.1 targets: ${{ matrix.target }} - name: Build web assets @@ -121,7 +122,8 @@ jobs: publish-release: needs: [release-meta, build] - runs-on: [self-hosted, unraid] + runs-on: ubuntu-24.04 + timeout-minutes: 60 steps: - name: Download release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 @@ -139,7 +141,8 @@ jobs: npm: needs: [release-meta, publish-release] - runs-on: [self-hosted, unraid] + runs-on: ubuntu-24.04 + timeout-minutes: 60 permissions: contents: read id-token: write @@ -152,6 +155,11 @@ jobs: node-version: 22 registry-url: https://registry.npmjs.org + - name: Validate npm publication identity + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm whoami >/dev/null + - name: Verify npm package version matches release tag id: npm-version shell: bash @@ -181,7 +189,7 @@ jobs: shell: bash run: | set -euo pipefail - if npm view "synapse-rmcp@${{ steps.npm-version.outputs.version }}" version >/dev/null 2>&1; then + if npm view "@dinglebear/synapse@${{ steps.npm-version.outputs.version }}" version >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" @@ -200,12 +208,12 @@ jobs: cache="$(mktemp -d)" trap 'rm -rf "$cache"' EXIT for attempt in {1..12}; do - if npm view "synapse-rmcp@${{ steps.npm-version.outputs.version }}" version >/dev/null 2>&1; then + if npm view "@dinglebear/synapse@${{ steps.npm-version.outputs.version }}" version >/dev/null 2>&1; then break fi if [[ "$attempt" == 12 ]]; then - npm view "synapse-rmcp@${{ steps.npm-version.outputs.version }}" version + npm view "@dinglebear/synapse@${{ steps.npm-version.outputs.version }}" version fi sleep 5 done - npm_config_cache="$cache" npx -y "synapse-rmcp@${{ steps.npm-version.outputs.version }}" --version + npm_config_cache="$cache" npx -y "@dinglebear/synapse@${{ steps.npm-version.outputs.version }}" --version diff --git a/.github/workflows/repository-contract.yml b/.github/workflows/repository-contract.yml new file mode 100644 index 0000000..8eeb3dd --- /dev/null +++ b/.github/workflows/repository-contract.yml @@ -0,0 +1,40 @@ +name: Repository contract + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: repository-contract-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: {} + +jobs: + contract: + permissions: + contents: read + uses: dinglebear-ai/workflows/.github/workflows/fleet-contract.yml@d1a41a7af9c41189e0f1062234364f5814bda99d # fleet-2026-07-31 + with: + profile: rust + implementation-ref: d1a41a7af9c41189e0f1062234364f5814bda99d + secrets: inherit + + repository-contract: + name: Repository Contract + if: always() + needs: [contract] + runs-on: ci-pool-ops + timeout-minutes: 5 + permissions: {} + steps: + - name: Require repository contract + env: + RESULT: ${{ needs.contract.result }} + run: | + if [[ "$RESULT" != "success" ]]; then + echo "repository contract concluded $RESULT" >&2 + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index 196c2ae..5dc0280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,17 +18,15 @@ REST compatibility endpoint is `POST /v1/synapse`. |---|---| | Remote | `git@github.com:dinglebear-ai/synapse.git` | | Cargo workspace | 2 members: `.` (crate `synapse`) and `xtask` | -| Edition / MSRV | 2024 / `rust-version = 1.90` (`rust-toolchain.toml` pins channel `1.90`) | -| MCP framework | `rmcp = "2.2.0"` (exact string in `Cargo.toml`; verify against `Cargo.lock`) | +| Edition / MSRV | 2024 / `rust-version = 1.97.1` (`rust-toolchain.toml` pins channel `1.97.1`) | +| MCP framework | `rmcp = "=3.0.0-beta.2"` via `[workspace.dependencies]`; verify against `Cargo.lock` | | Binary | `synapse` (`[[bin]]`, `autobins = false`) | | Service port | `40080` | | Tools | two: `flux` and `scout` | | Action surface | **59 operations** in `OPERATION_SPECS` — 14 REST-reachable, 45 MCP-only | -`Cargo.toml`, `server.json`, and the plugin manifests still carry -`github.com/jmagar/synapse` URLs and the `ghcr.io/jmagar/synapse` image path. -Those resolve only through GitHub's org-transfer redirect. Do not "fix" the -`ghcr.io` path casually — it changes where deployments pull from. +Cargo, package, plugin, registry, and Compose metadata use the canonical +`dinglebear-ai/synapse` repository and GHCR namespaces. ## Module map @@ -236,7 +234,7 @@ just health `cargo-llvm-cov` coverage currently needs the direct-rustc workaround on this host when the local `sccache-wrapper` mishandles `--check-cfg`. Resolve the toolchain rather than hardcoding a version — `rust-toolchain.toml` pins channel -`1.90`, so a literal path pinned to some other release will silently be wrong: +`1.97.1`, so a literal path pinned to some other release will silently be wrong: ```bash env -u RUSTC_WRAPPER \ diff --git a/Cargo.lock b/Cargo.lock index 7c7c8a9..1939616 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -174,6 +174,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64ct" version = "1.8.3" @@ -210,7 +216,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" dependencies = [ - "base64", + "base64 0.22.1", "bollard-stubs", "bytes", "futures-core", @@ -1192,7 +1198,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1483,7 +1489,7 @@ version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac", @@ -1507,7 +1513,7 @@ version = "0.15.0" source = "git+https://github.com/dinglebear-ai/labby.git?rev=87cec3241f9baef22335de9cc8629ebbcd8ba047#87cec3241f9baef22335de9cc8629ebbcd8ba047" dependencies = [ "axum", - "base64", + "base64 0.22.1", "getrandom 0.4.2", "httpdate", "jsonwebtoken", @@ -1895,7 +1901,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2258,7 +2264,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2296,7 +2302,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2358,11 +2364,12 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695" dependencies = [ "async-trait", + "base64 0.23.0", "bytes", "chrono", "futures", @@ -2390,9 +2397,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "2.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79" dependencies = [ "darling", "proc-macro2", @@ -2960,9 +2967,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 524e567..c30a0ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,64 @@ -# ============================================================================= -# Cargo.toml — Workspace root for the synapse project -# -# TEMPLATE: This is a cargo workspace containing two crates: -# - . (the main library/binary crate) -# - xtask/ (repo automation: dist, ci, symlink-docs, check-env) -# -# When creating your own server, rename "synapse" and add your own -# workspace members if needed. -# ============================================================================= - [workspace] -# TEMPLATE: Add any additional crates here (e.g. a shared types crate). members = [".", "xtask"] -resolver = "2" +resolver = "3" -[package] -name = "synapse" +[workspace.package] version = "1.0.0" edition = "2024" -rust-version = "1.90" -categories = ["command-line-utilities", "development-tools"] -keywords = ["mcp", "docker", "ssh", "zfs", "homelab"] -repository = "https://github.com/jmagar/synapse" -homepage = "https://github.com/jmagar/synapse#readme" -description = "Rust MCP and CLI server for local Synapse workflows: Docker/Compose control, host inspection, SSH, logs, ZFS, and files." +rust-version = "1.97.1" +authors = ["dinglebear.ai "] license = "MIT" +readme = "README.md" +homepage = "https://github.com/dinglebear-ai/synapse#readme" +repository = "https://github.com/dinglebear-ai/synapse" + +[workspace.dependencies] +rmcp = { version = "=3.1.0", default-features = false } +schemars = "=1.2.1" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "warn" +invalid_codeblock_attributes = "deny" +invalid_html_tags = "deny" +invalid_rust_codeblocks = "deny" +bare_urls = "warn" +unescaped_backticks = "warn" +redundant_explicit_links = "warn" + +[workspace.lints.rust] +missing_docs = "allow" +missing_crate_level_docs = "deny" +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +mod_module_files = "deny" +missing_safety_doc = "deny" + +[package] +name = "synapse" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +categories = ["command-line-utilities", "virtualization", "filesystem", "network-programming", "development-tools"] +keywords = ["mcp", "docker", "ssh", "zfs", "homelab"] +description = "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer." +publish = false autobins = false +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] +targets = ["x86_64-unknown-linux-gnu"] + [[bin]] name = "synapse" path = "src/main.rs" @@ -38,7 +70,7 @@ futures = "0.3" # HTTP / MCP transport axum = "0.8" -rmcp = { version = "2.2.0", default-features = false, features = [ +rmcp = { workspace = true, features = [ "server", "macros", "transport-streamable-http-server", @@ -51,7 +83,7 @@ tower-http = { version = "0.7", features = ["cors", "limit", "trace"] } # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -schemars = "1.2.1" +schemars.workspace = true # Time chrono = { version = "0.4", features = ["serde"] } @@ -131,7 +163,7 @@ tokio = { version = "1", features = ["test-util"] } bytes = "1" tempfile = "3" tower = { version = "0.5", features = ["util"] } -rmcp = { version = "2.2.0", default-features = false, features = [ +rmcp = { workspace = true, features = [ "client", "transport-child-process", ] } diff --git a/README.md b/README.md index 596cf40..76a341d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # synapse-rmcp -Rust MCP and CLI server for local Synapse workflows — a full-parity port of -[synapse-mcp](https://github.com/jmagar/synapse-mcp) implemented in Rust with -the [rmcp](https://github.com/modelcontextprotocol/rust-sdk) framework. +MCP server and CLI for host and container operations: Docker and Compose control, +SSH, host inspection, logs, ZFS, and safe file transfer. + +Synapse is a full-parity Rust port of +[synapse-mcp](https://github.com/dinglebear-ai/synapse-mcp), built with the +[rmcp](https://github.com/modelcontextprotocol/rust-sdk) framework. The server exposes two MCP tools (`flux` and `scout`) plus equivalent CLI commands, covering all 59 production actions from the original TypeScript server. @@ -72,14 +75,14 @@ Use the npm launcher for stdio MCP or CLI access without a manual binary install: ```bash -npx -y synapse-rmcp --help -npx -y synapse-rmcp mcp +npx -y @dinglebear/synapse --help +npx -y @dinglebear/synapse mcp ``` For a permanent command: ```bash -npm i -g synapse-rmcp +npm i -g @dinglebear/synapse synapse --version ``` @@ -104,7 +107,7 @@ at `~/.synapse` on the host and `/data` in the container. The first-screen 30-second path is: ```bash -npx -y synapse-rmcp mcp +npx -y @dinglebear/synapse mcp ``` Then configure an MCP client with stdio: @@ -276,7 +279,7 @@ Distribution/version invariants: - The npm package downloads the matching GitHub Release binary. - The installed binary remains `synapse`. -- `server.json` must point at `ghcr.io/jmagar/synapse:`. +- `server.json` must point at `ghcr.io/dinglebear-ai/synapse:`. - Plugin manifests stay versionless where marketplaces derive identity from git state. - Generated docs and schemas must come from source-controlled generation diff --git a/config/Dockerfile b/config/Dockerfile index cc30879..e32e845 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -46,12 +46,12 @@ FROM docker@sha256:be132a9f282288de4afaf63379dff75711fda0147c6b72a9df44e51841402 # ── Stage 3: Builder ────────────────────────────────────────────────────────── # DIGEST-PIN: Refresh with: -# docker pull rust:1.90-slim-bookworm -# docker inspect --format '{{index .RepoDigests 0}}' rust:1.90-slim-bookworm +# docker pull rust:1.97.1-slim-bookworm +# docker inspect --format '{{index .RepoDigests 0}}' rust:1.97.1-slim-bookworm # Then replace the tag below with: -# FROM rust@sha256: AS builder # rust:1.90-slim-bookworm -# rust:1.90-slim-bookworm -FROM rust@sha256:64232e656c058f4468e8d024e990acff04f0fd5a5c0a88a574dc37773d7325c9 AS builder +# FROM rust@sha256: AS builder # rust:1.97.1-slim-bookworm +# rust:1.97.1-slim-bookworm +FROM rust@sha256:99e09cb2284e2ddbb73a995deee3e91783fd04d177602ccf6eab326d778ee777 AS builder WORKDIR /app diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bf21df9..5ebc7fa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,7 +31,7 @@ x-common-service: &common-service services: synapse: <<: *common-service - image: ghcr.io/jmagar/synapse:${SYNAPSE_VERSION:-latest} + image: ghcr.io/dinglebear-ai/synapse:${SYNAPSE_VERSION:-latest} container_name: synapse env_file: - path: .env diff --git a/docs/AGENTS-FIRST.md b/docs/AGENTS-FIRST.md index b6c140d..d1641de 100644 --- a/docs/AGENTS-FIRST.md +++ b/docs/AGENTS-FIRST.md @@ -1,3 +1,9 @@ +--- +title: "Agents-first design" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Agents-First Design" doc_type: "guide" diff --git a/docs/API.md b/docs/API.md index 2b0acef..25832d6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,3 +1,9 @@ +--- +title: "synapse API" +created: 2026-05-22 +updated: 2026-07-30 +--- + # synapse API `synapse` exposes two MCP tools (`flux` and `scout`) and equivalent CLI diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1374224..d03e428 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,3 +1,9 @@ +--- +title: "Architecture" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Architecture" doc_type: "guide" diff --git a/docs/AUTH.md b/docs/AUTH.md index 8d2e13e..6d3ceef 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -1,3 +1,9 @@ +--- +title: "Authentication" +created: 2026-05-22 +updated: 2026-07-30 +--- + # Authentication Synapse supports **static bearer tokens** and **OAuth 2.0** for the Streamable diff --git a/docs/CI.md b/docs/CI.md index 14e944c..9a61847 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,3 +1,9 @@ +--- +title: "CI" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "CI" doc_type: "guide" diff --git a/docs/CLI_DESTRUCTIVE_SMOKE.md b/docs/CLI_DESTRUCTIVE_SMOKE.md index 543bfd6..a23a69c 100644 --- a/docs/CLI_DESTRUCTIVE_SMOKE.md +++ b/docs/CLI_DESTRUCTIVE_SMOKE.md @@ -1,3 +1,9 @@ +--- +title: "Destructive CLI Smoke Route" +created: 2026-06-06 +updated: 2026-07-30 +--- + # Destructive CLI Smoke Route This route validates mutating `synapse flux` commands against disposable Docker diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 90167cd..43d2d1d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1,3 +1,9 @@ +--- +title: "synapse Configuration" +created: 2026-05-22 +updated: 2026-07-30 +--- + # synapse Configuration ## MCP diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index df25096..1e74989 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,3 +1,9 @@ +--- +title: "Deployment" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Deployment" doc_type: "guide" diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 0e23f29..f94f86a 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -1,3 +1,9 @@ +--- +title: "Docker" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Docker" doc_type: "guide" diff --git a/docs/DOCS.md b/docs/DOCS.md index b7e7b98..44b663c 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -1,3 +1,9 @@ +--- +title: "Documentation" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Documentation" doc_type: "guide" diff --git a/docs/ENV.md b/docs/ENV.md index 6ae3c18..1343907 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -1,3 +1,9 @@ +--- +title: "Environment variables" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Environment Variables" doc_type: "guide" diff --git a/docs/JUSTFILE.md b/docs/JUSTFILE.md index ef0c24c..77630d0 100644 --- a/docs/JUSTFILE.md +++ b/docs/JUSTFILE.md @@ -1,3 +1,9 @@ +--- +title: "Justfile" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Justfile" doc_type: "guide" diff --git a/docs/MCP-REGISTRY-PUBLISH-GUIDE.md b/docs/MCP-REGISTRY-PUBLISH-GUIDE.md index d6d92f4..04d1ced 100644 --- a/docs/MCP-REGISTRY-PUBLISH-GUIDE.md +++ b/docs/MCP-REGISTRY-PUBLISH-GUIDE.md @@ -1,3 +1,9 @@ +--- +title: "MCP Registry Publishing Guide" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "MCP Registry Publishing Guide" doc_type: "guide" @@ -26,8 +32,8 @@ using the `server.json` manifest at the repo root. | Field | Current value | |---|---| | `name` | `tv.tootie/synapse` | -| `repository.url` | `https://github.com/jmagar/synapse` | -| `packages[0].identifier` | `ghcr.io/jmagar/synapse:` | +| `repository.url` | `https://github.com/dinglebear-ai/synapse` | +| `packages[0].identifier` | `ghcr.io/dinglebear-ai/synapse:` | | Hosted remote | Not declared; add `remotes` only when a public hosted `/mcp` endpoint exists | ## Prerequisites @@ -104,7 +110,7 @@ tag before calling the publisher: run: | VERSION="${GITHUB_REF_NAME#v}" jq --arg v "$VERSION" \ - --arg img "ghcr.io/jmagar/synapse:${VERSION}" \ + --arg img "ghcr.io/dinglebear-ai/synapse:${VERSION}" \ '.version = $v | .packages[0].identifier = $img | .packages[0].version = $v' \ server.json > server.tmp && mv server.tmp server.json diff --git a/docs/MCPORTER.md b/docs/MCPORTER.md index 22b3c48..d132e0f 100644 --- a/docs/MCPORTER.md +++ b/docs/MCPORTER.md @@ -1,3 +1,9 @@ +--- +title: "mcporter" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "mcporter Integration Testing" doc_type: "guide" diff --git a/docs/MCP_SCHEMA.md b/docs/MCP_SCHEMA.md index 292ceba..5ccd379 100644 --- a/docs/MCP_SCHEMA.md +++ b/docs/MCP_SCHEMA.md @@ -1,3 +1,9 @@ +--- +title: "synapse MCP Schema Contract" +created: 2026-08-01 +updated: 2026-08-01 +--- + # synapse MCP Schema Contract `synapse` exposes two MCP tools: `flux` and `scout`. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 8c4041b..c1d5514 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -1,3 +1,9 @@ +--- +title: "Observability" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Observability" doc_type: "guide" diff --git a/docs/PATTERNS.md b/docs/PATTERNS.md index d1ee6a0..1836e0a 100644 --- a/docs/PATTERNS.md +++ b/docs/PATTERNS.md @@ -1,3 +1,9 @@ +--- +title: "Synapse Patterns" +created: 2026-05-22 +updated: 2026-07-30 +--- + # Synapse Patterns Synapse follows the rmcp-server family patterns maintained upstream in: diff --git a/docs/PHILOSOPHY.md b/docs/PHILOSOPHY.md index c946e43..7bd2747 100644 --- a/docs/PHILOSOPHY.md +++ b/docs/PHILOSOPHY.md @@ -1,3 +1,9 @@ +--- +title: "Philosophy" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Philosophy" doc_type: "guide" diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 94e9065..c57304a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1,3 +1,9 @@ +--- +title: "Plugin Surfaces" +created: 2026-05-22 +updated: 2026-07-30 +--- + # Plugin Surfaces Synapse ships one service plugin package with three host-specific entrypoints: diff --git a/docs/PRE-COMMIT.md b/docs/PRE-COMMIT.md index b969bdc..c085c31 100644 --- a/docs/PRE-COMMIT.md +++ b/docs/PRE-COMMIT.md @@ -1,3 +1,9 @@ +--- +title: "Pre-commit" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Pre-commit Hooks" doc_type: "guide" diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 652630a..18d563b 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,3 +1,9 @@ +--- +title: "synapse Quickstart" +created: 2026-05-22 +updated: 2026-07-30 +--- + # synapse Quickstart This guide gets a local checkout running with loopback-only no-auth MCP HTTP, diff --git a/docs/RUST.md b/docs/RUST.md index 05457fd..01a95af 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -1,3 +1,9 @@ +--- +title: "Rust Build Setup" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Rust Build Setup" doc_type: "guide" diff --git a/docs/SCRIPTS.md b/docs/SCRIPTS.md index b7e12d2..57e9afc 100644 --- a/docs/SCRIPTS.md +++ b/docs/SCRIPTS.md @@ -1,3 +1,9 @@ +--- +title: "Scripts" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Scripts" doc_type: "guide" @@ -94,7 +100,7 @@ preflight() { One-line install: ```bash -curl -fsSL https://raw.githubusercontent.com/jmagar/synapse/main/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/dinglebear-ai/synapse/main/install.sh | bash ``` After install: `synapse doctor` to validate the environment. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index bd661df..526f4f8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,3 +1,9 @@ +--- +title: "Security model" +created: 2026-07-28 +updated: 2026-07-30 +--- + --- title: "Security Model" doc_type: "guide" diff --git a/docs/SYSTEMD.md b/docs/SYSTEMD.md index dea3684..92cd944 100644 --- a/docs/SYSTEMD.md +++ b/docs/SYSTEMD.md @@ -1,3 +1,9 @@ +--- +title: "systemd" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "systemd Deployment" doc_type: "guide" diff --git a/docs/TESTING.md b/docs/TESTING.md index cad69e7..80c4a34 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,3 +1,9 @@ +--- +title: "Testing" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Testing" doc_type: "guide" diff --git a/docs/WEB.md b/docs/WEB.md index 96a882e..b752461 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -1,3 +1,9 @@ +--- +title: "Web UI" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "Web UI" doc_type: "guide" diff --git a/docs/XTASKS.md b/docs/XTASKS.md index e1965f3..7f4c690 100644 --- a/docs/XTASKS.md +++ b/docs/XTASKS.md @@ -1,3 +1,9 @@ +--- +title: "xtasks" +created: 2026-05-22 +updated: 2026-07-30 +--- + --- title: "xtasks" doc_type: "guide" diff --git a/docs/specs/scaffold-intent-handoff.md b/docs/specs/scaffold-intent-handoff.md index 767fc90..cc30c36 100644 --- a/docs/specs/scaffold-intent-handoff.md +++ b/docs/specs/scaffold-intent-handoff.md @@ -1,3 +1,9 @@ +--- +title: "Scaffold Intent Handoff Spec" +created: "2026-07-30" +updated: "2026-07-30" +--- + # Scaffold Intent Handoff Spec ## Status diff --git a/install.sh b/install.sh index 3750cb1..d555952 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ # service's actual binary name, URL, and version. # # Usage: -# curl -fsSL https://raw.githubusercontent.com/jmagar/synapse/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/dinglebear-ai/synapse/main/install.sh | bash # # or locally: # bash install.sh # @@ -24,7 +24,7 @@ set -euo pipefail # ── CONFIGURATION — edit these values for your service ─────────────────────── # GitHub org/repo. -REPO="jmagar/synapse" +REPO="dinglebear-ai/synapse" # Binary name (matches Cargo.toml [[bin]] name). BINARY_NAME="synapse" diff --git a/packages/synapse-rmcp/LICENSE b/packages/synapse-rmcp/LICENSE new file mode 100644 index 0000000..59a53b2 --- /dev/null +++ b/packages/synapse-rmcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jacob Magar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/synapse-rmcp/README.md b/packages/synapse-rmcp/README.md index a3f04f5..76a341d 100644 --- a/packages/synapse-rmcp/README.md +++ b/packages/synapse-rmcp/README.md @@ -1,8 +1,11 @@ # synapse-rmcp -Rust MCP and CLI server for local Synapse workflows — a full-parity port of -[synapse-mcp](https://github.com/jmagar/synapse-mcp) implemented in Rust with -the [rmcp](https://github.com/modelcontextprotocol/rust-sdk) framework. +MCP server and CLI for host and container operations: Docker and Compose control, +SSH, host inspection, logs, ZFS, and safe file transfer. + +Synapse is a full-parity Rust port of +[synapse-mcp](https://github.com/dinglebear-ai/synapse-mcp), built with the +[rmcp](https://github.com/modelcontextprotocol/rust-sdk) framework. The server exposes two MCP tools (`flux` and `scout`) plus equivalent CLI commands, covering all 59 production actions from the original TypeScript server. @@ -14,18 +17,22 @@ commands, covering all 59 production actions from the original TypeScript server - [Install](#install) - [Quickstart](#quickstart) - [Client Configuration](#client-configuration) +- [Plugin Packages](#plugin-packages) - [Runtime Surfaces](#runtime-surfaces) - [MCP Tool Reference](#mcp-tool-reference) - [CLI Reference](#cli-reference) -- [Configuration](#configuration) - [Authentication](#authentication) - [Safety And Trust Model](#safety-and-trust-model) -- [Architecture](#architecture) - [Distribution Contract](#distribution-contract) -- [Development](#development) - [Verification](#verification) - [Deployment](#deployment) - [Troubleshooting](#troubleshooting) +- [Tools and Actions](#tools-and-actions) +- [Known Parity Gaps](#known-parity-gaps) +- [Configuration](#configuration) +- [Run](#run) +- [Architecture](#architecture) +- [Development](#development) - [Documentation](#documentation) - [Related Servers](#related-servers) - [License](#license) @@ -49,7 +56,8 @@ workflows through two MCP tools and the equivalent CLI: - `flux` manages Docker infrastructure, containers, Compose projects, and host inspection. - `scout` handles SSH/local host inspection, safe file reads, allowlisted - command execution, ZFS introspection, and log retrieval. + command execution, bounded descriptor-confined file transfer, ZFS introspection, + and log retrieval. - REST exists only as a compatibility shim for a subset of actions. - The web surface is a lightweight static admin shell, not a full dashboard. @@ -67,29 +75,39 @@ Use the npm launcher for stdio MCP or CLI access without a manual binary install: ```bash -npx -y synapse-rmcp --help -npx -y synapse-rmcp mcp +npx -y @dinglebear/synapse --help +npx -y @dinglebear/synapse mcp ``` For a permanent command: ```bash -npm i -g synapse-rmcp +npm i -g @dinglebear/synapse synapse --version ``` +The npm package downloads the `synapse` binary from GitHub Releases during +`postinstall`, keeping the release tag aligned with +`packages/synapse-rmcp/package.json`. + From source: ```bash cargo build --release ``` +The production image includes Python 3 plus the official Docker CLI/Compose +plugin because Scout's remote descriptor wrappers and Flux Compose operations +invoke those runtime tools. The image does not contain a Docker daemon; Flux +uses the mounted socket or SSH-forwarded remote socket. Persistent appdata lives +at `~/.synapse` on the host and `/data` in the container. + ## Quickstart The first-screen 30-second path is: ```bash -npx -y synapse-rmcp mcp +npx -y @dinglebear/synapse mcp ``` Then configure an MCP client with stdio: @@ -151,6 +169,26 @@ Streamable HTTP uses `/mcp` on the configured host and port: } ``` +## Plugin Packages + +`plugins/synapse/` ships Claude Code, Codex, and Gemini CLI manifests that all +point at the same HTTP MCP endpoint and the same shared skill. + +These packages contain **no lifecycle hooks**. Connecting to a server that is +already running needs no setup — the manifests substitute your `server_url` and +`api_token` directly. If this machine also *runs* the server, bootstrap it once +by hand: + +```bash +synapse setup install # put/refresh the binary on PATH +synapse setup plugin-hook # check, then repair on blocking failures +synapse setup plugin-hook --no-repair # audit only; never mutates appdata +``` + +Export the relevant `SYNAPSE_*` variables (or write them to `~/.synapse/.env`) +first; `plugins/README.md` maps each plugin option to its variable. Re-run +`synapse setup install` after a plugin update. + ## Runtime Surfaces | Surface | Status | Purpose | @@ -212,9 +250,24 @@ matrix. Synapse separates read and write scopes (`synapse:read`, `synapse:write`) and uses confirmation gates for destructive operations. `SYNAPSE_MCP_ALLOW_DESTRUCTIVE` -can skip prompts only in loopback-safe contexts. SSH host trust is delegated to -OpenSSH known-hosts behavior, and command execution uses execvp/argv semantics -without shell interpolation. +can skip prompts only in loopback-safe contexts. Host `protocol` is authoritative +and defaults to SSH when omitted; local execution requires the explicit built-in +`local` host or `protocol: "local"`. SSH host trust is delegated to OpenSSH +known-hosts behavior, and command execution uses execvp/argv semantics without +shell interpolation. `scout beam` enforces both endpoints' configured read roots, +blocks sensitive and symlinked paths, and caps each transfer at 64 MiB. + +`scout exec` and `scout emit` accept only these 18 typed commands +(`ALLOWED_READ_COMMANDS` in `src/synapse/command_policy.rs`): + +`cat`, `head`, `tail`, `grep`, `rg`, `ls`, `tree`, `wc`, `uniq`, `diff`, `stat`, +`file`, `du`, `df`, `pwd`, `hostname`, `uptime`, `whoami` + +`git` is deliberately excluded, as are shells, interpreters, network clients, and +mutating tools (`EXEC_DENYLIST`). There is no `find` or `sort` in the allowlist — +use the dedicated `scout find` action for filesystem search. Per-host custom +commands may be enabled via `execAllowlist`, but receive a zero-argument policy +until a typed argument policy is registered. ## Distribution Contract @@ -226,7 +279,7 @@ Distribution/version invariants: - The npm package downloads the matching GitHub Release binary. - The installed binary remains `synapse`. -- `server.json` must point at `ghcr.io/jmagar/synapse:`. +- `server.json` must point at `ghcr.io/dinglebear-ai/synapse:`. - Plugin manifests stay versionless where marketplaces derive identity from git state. - Generated docs and schemas must come from source-controlled generation @@ -235,7 +288,7 @@ Distribution/version invariants: ## Verification ```bash -python3 /home/jmagar/workspace/soma/scripts/check-readme-guide.py README.md +just validate-plugin # plugin manifests, MCP config, monitors, skills npm --prefix packages/synapse-rmcp run check cargo fmt --check cargo check @@ -268,40 +321,6 @@ present; local operator usage can stay on stdio. - SSH errors: verify OpenSSH known_hosts, mux/socket availability, and host reachability outside Synapse first. -## npm / npx - -Run the stdio MCP server or CLI without a manual binary install: - -```bash -npx -y synapse-rmcp --help -``` - -MCP clients can use the same launcher: - -```json -{ - "mcpServers": { - "synapse": { - "command": "npx", - "args": ["-y", "synapse-rmcp"] - } - } -} -``` - -The npm package downloads the `synapse` binary from GitHub Releases during `postinstall` and keeps the release tag aligned with `packages/synapse-rmcp/package.json`. - -Across the rmcp family, naming follows `repo=-rmcp`, `npm=-rmcp`, and `CLI=r`. Synapse is the exception: the npm package is `synapse-rmcp`, but the installed CLI and binary alias remain `synapse`. - -## Surfaces - -| Surface | Status | Purpose | -|---|---:|---| -| MCP | Required | Agent-facing `flux` and `scout` tools | -| CLI | Required | Scriptable parity surface | -| REST | Present | Thin local action endpoint | -| Web | Present | Lightweight static admin shell | - ## Tools and Actions ### `flux` — Docker infrastructure management @@ -390,7 +409,7 @@ Across the rmcp family, naming follows `repo=-rmcp`, `npm=-rmc | `delta` | `synapse:read` | Compare files or content; requires `source_host`, `source_path`; then either `target_host`+`target_path` or `content` | | `exec` | `synapse:write` | Execute allowlisted command (destructive, execvp); requires `host`, `command`; optional `path`, `args`, `timeout_secs` | | `emit` | `synapse:write` | Multi-host execution (destructive); requires `targets` array, `command`; optional `args`, `timeout_secs` | -| `beam` | `synapse:write` | File transfer between hosts (destructive); requires `source_host`, `source_path`, `dest_host`, `dest_path` | +| `beam` | `synapse:write` | Bounded root-confined file transfer (destructive); requires `source_host`, `source_path`, `dest_host`, `dest_path`; both paths must be under configured Scout/Compose roots | #### `scout zfs` — ZFS introspection (3 subactions) @@ -426,7 +445,7 @@ the following features from the original TypeScript server are **not yet ported* | Feature | Description | |---|---| | `claude/channel` notifications | Original forwards Docker events and log tails as `notifications/claude/channel` MCP notifications. No equivalent exists in Rust. | -| Templated MCP resources | Original exposes `synapse://hosts/{host}`, `synapse://hosts/{host}/stacks`, `synapse://stacks`, `synapse://stacks/{host}/{stack}`, `synapse://stacks/{host}/{stack}/env`, `synapse://containers/{host}`, `synapse://containers/{host}/{id}`. Rust exposes schema resources, `synapse://hosts`, `synapse://compose/projects`, and help resources. | +| Templated MCP resources | Original exposes `synapse://hosts/{host}`, `synapse://hosts/{host}/stacks`, `synapse://stacks`, `synapse://stacks/{host}/{stack}`, `synapse://stacks/{host}/{stack}/env`, `synapse://containers/{host}`, `synapse://containers/{host}/{id}`. Rust exposes tool-specific schema and help resources plus read-scoped `synapse://hosts`, `synapse://compose/projects`, `synapse://status`, and `synapse://activity`. | | Root SSH login gate | Original gates `sshUser=root` through elicitation unless `SYNAPSE_ALLOW_ROOT_LOGIN=true`. Rust has destructive-operation elicitation but no root-login gate. | | TOFU fingerprint store | Original persists fingerprints to `~/.config/synapse/known_hosts.json` and rejects changed fingerprints. Rust uses strict OpenSSH `known_hosts` with wildcard warnings — different operator behavior. | | `SYNAPSE_EXCLUDE_HOSTS` | Original env var to exclude hosts from fleet discovery is absent in Rust. | @@ -452,10 +471,13 @@ Key environment variables: | `SYNAPSE_MCP_NO_AUTH` | `false` | Disable auth for loopback development only. | | `SYNAPSE_NOAUTH` | `false` | Delegate auth/authz to an isolated trusted upstream gateway. | | `SYNAPSE_MCP_ALLOW_DESTRUCTIVE` | `false` | Skip destructive-operation confirmation prompts (loopback only). | -| `SYNAPSE_MCP_MAX_CONCURRENCY` | `50` | Maximum simultaneous in-flight requests on `/mcp` and `/v1/synapse`. Excess requests receive HTTP 429 with `Retry-After`. Set to `0` to disable. `/health`, `/ready`, and `/status` are exempt. | +| `SYNAPSE_MCP_MAX_CONCURRENCY` | `50` | Maximum simultaneous in-flight operational requests. Excess requests receive HTTP 429 with `Retry-After`. Set to `0` to disable. Public probes, OAuth discovery, and static assets are exempt from concurrency shedding. | +| `SYNAPSE_MCP_PUBLIC_URL` | unset | OAuth public URL; HTTPS required except loopback development. | +| `SYNAPSE_HOSTS_CONFIG` | unset | Inline host topology. Omitted host protocols default to SSH; local execution requires `protocol: "local"`. | -See `.env.example` for the full list of variables and `docs/CONFIG.md` for auth -configuration details. +See `.env.example` for the full list of variables, `docs/CONFIG.md` for +configuration details, and `docs/SECURITY.md` for transport, path, transfer, +container, and CI runner trust boundaries. ## Run @@ -538,17 +560,17 @@ Source-of-truth docs and code are split as follows: ## Related Servers -- `unifi-rmcp / rustifi` - UniFi controller REST API bridge. -- `tailscale-rmcp / rustscale` - Tailscale API bridge for devices, users, and tailnet operations. -- `unraid-rmcp / unrust` - Unraid GraphQL bridge for NAS and server management. -- `apprise-rmcp` - Apprise notification fan-out bridge for many delivery backends. -- `gotify-rmcp` - Gotify push notification bridge for sends, messages, apps, and clients. -- `arcane-rmcp` - Arcane Docker management bridge for containers and related resources. -- `yarr-rmcp` - Media-stack bridge for Sonarr, Radarr, Prowlarr, Plex, and related services. -- `ytdl-mcp` - Media download and metadata workflow server. +- `runifi` - UniFi controller REST API bridge. +- `rtailscale` - Tailscale API bridge for devices, users, and tailnet operations. +- `unraid` / `runraid` - Unraid GraphQL bridge for NAS and server management. +- `rapprise` - Apprise notification fan-out bridge for many delivery backends. +- `rgotify` - Gotify push notification bridge for sends, messages, apps, and clients. +- `rarcane` - Arcane Docker management bridge for containers and related resources. +- `yarr` - Media-stack bridge for Sonarr, Radarr, Prowlarr, Plex, and related services. +- `rytdl` - Media download and metadata workflow server. - `cortex` - Syslog and homelab log aggregation MCP server. - `axon` - RAG, crawl, scrape, extract, and semantic search project. -- `lab` - Homelab control plane and Labby gateway project. +- `labby` - Homelab control plane and Labby gateway project. - `lumen` - Local semantic code search MCP server. - `nugs` - Project/package management helper for local agent workflows. - `agentcast` - Agent transcript and activity publishing project. diff --git a/packages/synapse-rmcp/lib/platform.js b/packages/synapse-rmcp/lib/platform.js index 3775465..9c4d863 100644 --- a/packages/synapse-rmcp/lib/platform.js +++ b/packages/synapse-rmcp/lib/platform.js @@ -27,7 +27,7 @@ function releaseVersion(env = process.env) { } function releaseBaseUrl(env = process.env) { - const repo = env.SYNAPSE_RMCP_REPO || "jmagar/synapse"; + const repo = env.SYNAPSE_RMCP_REPO || "dinglebear-ai/synapse"; return env.SYNAPSE_RMCP_RELEASE_BASE_URL || `https://github.com/${repo}/releases/download`; } diff --git a/packages/synapse-rmcp/package.json b/packages/synapse-rmcp/package.json index 9d5e85a..02cf6fb 100644 --- a/packages/synapse-rmcp/package.json +++ b/packages/synapse-rmcp/package.json @@ -1,16 +1,16 @@ { - "name": "synapse-rmcp", + "name": "@dinglebear/synapse", "version": "1.0.0", - "description": "Node launcher for the Synapse Rust MCP server and CLI for Docker, host, SSH, log, ZFS, and file workflows.", + "description": "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer.", "license": "MIT", - "homepage": "https://github.com/jmagar/synapse#readme", + "homepage": "https://github.com/dinglebear-ai/synapse#readme", "repository": { "type": "git", - "url": "git+https://github.com/jmagar/synapse.git", + "url": "git+https://github.com/dinglebear-ai/synapse.git", "directory": "packages/synapse-rmcp" }, "bugs": { - "url": "https://github.com/jmagar/synapse/issues" + "url": "https://github.com/dinglebear-ai/synapse/issues" }, "bin": { "synapse": "bin/synapse.js" @@ -19,12 +19,16 @@ "bin/", "lib/", "scripts/", - "README.md" + "README.md", + "LICENSE*", + "package.json" ], "scripts": { "postinstall": "node scripts/install.js", "test": "node --test", - "check": "node --check bin/synapse.js && node --check scripts/install.js && node --check lib/platform.js" + "check": "node --check bin/synapse.js && node --check scripts/install.js && node --check lib/platform.js && node --check scripts/sync-readme.js && node --check scripts/check-package.js && node scripts/check-package.js", + "prepack": "node scripts/sync-readme.js", + "prepublishOnly": "node scripts/check-package.js --release" }, "engines": { "node": ">=18" @@ -49,5 +53,12 @@ "codex", "gemini" ], - "mcpName": "ai.dinglebear/synapse-rmcp" + "mcpName": "ai.dinglebear/synapse", + "publishConfig": { + "access": "public" + }, + "author": { + "name": "dinglebear.ai", + "url": "https://dinglebear.ai" + } } diff --git a/packages/synapse-rmcp/scripts/check-package.js b/packages/synapse-rmcp/scripts/check-package.js new file mode 100644 index 0000000..7149105 --- /dev/null +++ b/packages/synapse-rmcp/scripts/check-package.js @@ -0,0 +1,469 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const https = require("node:https"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const packageRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(packageRoot, "..", ".."); +const packageJsonPath = path.join(packageRoot, "package.json"); +const packageJson = readJson(packageJsonPath); +const expectedPackageName = "@dinglebear/synapse"; +const releaseMode = process.argv.includes("--release"); +const skipReleaseAssets = process.argv.includes("--skip-release-assets"); + +const failures = []; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function fail(message) { + failures.push(message); +} + +function assert(condition, message) { + if (!condition) { + fail(message); + } +} + +function normalizeRepoUrl(url) { + return String(url || "") + .replace(/^git\+/, "") + .replace(/#readme$/, "") + .replace(/\.git$/, "") + .replace(/\/$/, ""); +} + +function normalizeHomepage(url) { + return String(url || "").replace(/#readme$/, "").replace(/\/$/, ""); +} + +function repoLicenseFiles() { + return fs + .readdirSync(repoRoot) + .filter((entry) => /^licen[cs]e/i.test(entry)) + .filter((entry) => fs.statSync(path.join(repoRoot, entry)).isFile()) + .sort(); +} + +function compareFiles(left, right) { + if (!fs.existsSync(left) || !fs.existsSync(right)) { + return false; + } + + return fs.readFileSync(left).equals(fs.readFileSync(right)); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || packageRoot, + env: options.env || process.env, + encoding: "utf8", + stdio: options.stdio || ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || `${command} ${args.join(" ")} failed`).trim(); + throw new Error(detail); + } + + return result; +} + +function checkSyncedFiles() { + assert( + compareFiles(path.join(repoRoot, "README.md"), path.join(packageRoot, "README.md")), + "package README.md must be byte-identical to the repo README.md; run npm pack or scripts/sync-readme.js", + ); + + const licenses = repoLicenseFiles(); + assert(licenses.length > 0, "repo must have at least one LICENSE file to sync into the npm package"); + + for (const license of licenses) { + assert( + compareFiles(path.join(repoRoot, license), path.join(packageRoot, license)), + `package ${license} must be byte-identical to repo ${license}; run npm pack or scripts/sync-readme.js`, + ); + } +} + +function findNpmPackage(serverJson) { + return (serverJson.packages || []).find( + (entry) => entry.registryType === "npm" || entry.identifier === packageJson.name, + ); +} + +function checkMetadata() { + const serverPath = path.join(repoRoot, "server.json"); + assert(fs.existsSync(serverPath), "repo must contain server.json for package metadata checks"); + + if (!fs.existsSync(serverPath)) { + return; + } + + const serverJson = readJson(serverPath); + const npmPackage = findNpmPackage(serverJson); + const repoUrl = normalizeRepoUrl(packageJson.repository && packageJson.repository.url); + const serverRepoUrl = normalizeRepoUrl(serverJson.repository && serverJson.repository.url); + const homepage = normalizeHomepage(packageJson.homepage); + const serverWebsite = normalizeHomepage(serverJson.websiteUrl); + + assert(packageJson.name, "package.json must include name"); + assert( + packageJson.name === expectedPackageName, + "package.json name must match the dinglebear organization package", + ); + assert( + packageJson.publishConfig && packageJson.publishConfig.access === "public", + "scoped npm package must publish with public access", + ); + assert(packageJson.version, "package.json must include version"); + assert(packageJson.description, "package.json must include description"); + assert(packageJson.license, "package.json must include license"); + assert(packageJson.author && packageJson.author.name === "dinglebear.ai", "package.json author must be dinglebear.ai"); + assert(packageJson.engines && packageJson.engines.node, "package.json must declare engines.node"); + assert(packageJson.repository && packageJson.repository.type === "git", "package.json repository.type must be git"); + assert(packageJson.repository && packageJson.repository.directory, "package.json repository.directory must point at this package"); + assert(packageJson.repository && packageJson.repository.directory === path.relative(repoRoot, packageRoot), "package.json repository.directory must match package path"); + assert(packageJson.bugs && packageJson.bugs.url, "package.json must include bugs.url"); + assert(packageJson.mcpName === serverJson.name, "package.json mcpName must match server.json name"); + assert(repoUrl === serverRepoUrl, `package repository ${repoUrl} must match server.json repository ${serverRepoUrl}`); + assert(homepage === serverWebsite, `package homepage ${homepage} must match server.json websiteUrl ${serverWebsite}`); + assert(serverJson.version === packageJson.version, `package version ${packageJson.version} must match server.json version ${serverJson.version}`); + + if (npmPackage) { + assert(npmPackage.identifier === packageJson.name, "server.json npm package identifier must match package name"); + assert(npmPackage.version === packageJson.version, "server.json npm package version must match package version"); + } else { + fail("server.json must include an npm package entry"); + } + + const publisherMeta = serverJson._meta && serverJson._meta["io.modelcontextprotocol.registry/publisher-provided"]; + const distribution = publisherMeta && publisherMeta.distribution; + if (distribution) { + if (distribution.npm) { + assert( + distribution.npm === `${packageJson.name}@${packageJson.version}`, + `server.json distribution.npm must be ${packageJson.name}@${packageJson.version}`, + ); + } + if (distribution.nodePackage) { + assert(distribution.nodePackage === packageJson.name, "server.json distribution.nodePackage must match package name"); + } + } + + for (const keyword of ["mcp", "mcp-server", "model-context-protocol"]) { + assert(Array.isArray(packageJson.keywords) && packageJson.keywords.includes(keyword), `package keywords must include ${keyword}`); + } + + for (const field of ["bin/", "scripts/", "README.md", "LICENSE*", "package.json"]) { + assert(Array.isArray(packageJson.files) && packageJson.files.includes(field), `package files must include ${field}`); + } + + assert(packageJson.scripts && packageJson.scripts.prepack === "node scripts/sync-readme.js", "package prepack must sync README/LICENSE files"); + assert(packageJson.scripts && packageJson.scripts.prepublishOnly === "node scripts/check-package.js --release", "package prepublishOnly must run the release gate"); + assert(packageJson.scripts && packageJson.scripts.check && packageJson.scripts.check.includes("node scripts/check-package.js"), "package check must run the package verifier"); +} + +function assertRuntimeScriptsDoNotEscapePackage() { + const runtimeScripts = [packageJson.scripts && packageJson.scripts.postinstall].filter(Boolean); + for (const script of runtimeScripts) { + assert(!script.includes("../"), `runtime npm script must not reference files outside the package: ${script}`); + } + + const runtimeFiles = []; + if (packageJson.bin) { + for (const relative of Object.values(packageJson.bin)) { + runtimeFiles.push(relative); + } + } + for (const relative of ["scripts/install.js", "lib/platform.js"]) { + const absolute = path.join(packageRoot, relative); + if (fs.existsSync(absolute)) { + runtimeFiles.push(relative); + } + } + + for (const relative of new Set(runtimeFiles)) { + const text = fs.readFileSync(path.join(packageRoot, relative), "utf8"); + assert(!text.includes('"..", ".."'), `${relative} must not resolve paths outside the package at runtime`); + assert(!text.includes("'..', '..'"), `${relative} must not resolve paths outside the package at runtime`); + } + + const installer = path.join(packageRoot, "scripts", "install.js"); + if (fs.existsSync(installer)) { + const text = fs.readFileSync(installer, "utf8"); + assert(text.includes("verifyChecksum"), "postinstall downloader must verify release checksums"); + assert(text.includes("sha256"), "postinstall downloader must validate SHA-256 checksums"); + } +} + +function packTarball(tempDir) { + run("npm", ["pack", packageRoot, "--pack-destination", tempDir], { cwd: packageRoot }); + const tarballs = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".tgz")); + assert(tarballs.length === 1, "npm pack must produce exactly one tarball"); + return path.join(tempDir, tarballs[0]); +} + +function tarList(tarball) { + return run("tar", ["-tzf", tarball]).stdout.trim().split(/\n+/).filter(Boolean); +} + +function tarExtract(tarball, entry) { + return run("tar", ["-xOzf", tarball, entry]).stdout; +} + +function checkPacklist(tarball) { + const entries = tarList(tarball); + const allowedPrefixes = ["package/bin/", "package/lib/", "package/scripts/"]; + const allowedExact = new Set(["package/package.json", "package/README.md"]); + + for (const license of repoLicenseFiles()) { + allowedExact.add(`package/${license}`); + } + + for (const required of allowedExact) { + assert(entries.includes(required), `packed tarball missing ${required}`); + } + + const forbiddenFragments = [ + "node_modules", + "vendor/", + "test/", + ".env", + "Cargo.", + "target/", + "package-lock.json", + "pnpm-lock.yaml", + ".tgz", + ]; + + for (const entry of entries) { + const allowed = allowedExact.has(entry) || allowedPrefixes.some((prefix) => entry.startsWith(prefix)); + assert(allowed, `packed tarball contains unexpected file ${entry}`); + for (const fragment of forbiddenFragments) { + assert(!entry.includes(fragment), `packed tarball must not contain ${entry}`); + } + } + + assert( + Buffer.from(tarExtract(tarball, "package/README.md")).equals(fs.readFileSync(path.join(repoRoot, "README.md"))), + "packed README.md must match repo README.md", + ); + + for (const license of repoLicenseFiles()) { + assert( + Buffer.from(tarExtract(tarball, `package/${license}`)).equals(fs.readFileSync(path.join(repoRoot, license))), + `packed ${license} must match repo ${license}`, + ); + } +} + +function writeSmokeBinary(destination) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync( + destination, + "#!/usr/bin/env node\nprocess.stdout.write(`package-smoke-ok ${process.argv.slice(2).join(\" \")}\\n`);\n", + { mode: 0o755 }, + ); + fs.chmodSync(destination, 0o755); +} + +function checkInstalledBins(installRoot) { + const installedPackageRoot = path.join(installRoot, "node_modules", packageJson.name); + assert(fs.existsSync(installedPackageRoot), "tarball install must create package under node_modules"); + + const env = { ...process.env }; + if (packageJson.name === "soma-rmcp") { + const fakeBinary = path.join(installRoot, "fake-soma"); + writeSmokeBinary(fakeBinary); + env.SOMA_BIN = fakeBinary; + } else { + const platformPath = path.join(installedPackageRoot, "lib", "platform.js"); + assert(fs.existsSync(platformPath), "downloaded-binary packages must include lib/platform.js"); + if (fs.existsSync(platformPath)) { + const platform = require(platformPath); + writeSmokeBinary(platform.binaryPath()); + } + } + + for (const [name, relative] of Object.entries(packageJson.bin || {})) { + const binPath = path.join(installedPackageRoot, relative); + const result = spawnSync(process.execPath, [binPath, "--package-smoke"], { + cwd: installRoot, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const output = `${result.stdout || ""}${result.stderr || ""}`; + assert(result.status === 0, `${name} bin smoke exited ${result.status}: ${output.trim()}`); + assert(output.includes("package-smoke-ok --package-smoke"), `${name} bin smoke did not invoke the installed binary`); + } +} + +function checkTarballInstallSmoke(tarball, tempDir) { + const installRoot = path.join(tempDir, "consumer"); + fs.mkdirSync(installRoot, { recursive: true }); + run("npm", ["install", "--ignore-scripts", "--prefix", installRoot, tarball], { cwd: tempDir }); + checkInstalledBins(installRoot); +} + +function supportedTargets(platform) { + const tuples = [ + ["linux", "x64"], + ["win32", "x64"], + ]; + const targets = []; + for (const [osName, arch] of tuples) { + try { + const target = platform.targetFor(osName, arch); + targets.push({ osName, arch, target }); + } catch (_) { + // Unsupported target; ignore. + } + } + return targets; +} + +function requestHead(url, redirects = 0) { + return new Promise((resolve, reject) => { + const request = https.request(url, { method: "HEAD" }, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { + response.resume(); + if (redirects > 8) { + reject(new Error(`too many redirects for ${url}`)); + return; + } + requestHead(new URL(response.headers.location, url).toString(), redirects + 1).then(resolve, reject); + return; + } + + response.resume(); + resolve(response.statusCode); + }); + + request.setTimeout(15000, () => { + request.destroy(new Error(`timeout checking ${url}`)); + }); + request.on("error", reject); + request.end(); + }); +} + +function requestText(url, redirects = 0) { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { + response.resume(); + if (redirects > 8) { + reject(new Error(`too many redirects for ${url}`)); + return; + } + requestText(new URL(response.headers.location, url).toString(), redirects + 1).then(resolve, reject); + return; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + reject(new Error(`${url} returned ${response.statusCode}`)); + return; + } + + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => resolve(body)); + }) + .on("error", reject) + .setTimeout(15000, function onTimeout() { + this.destroy(new Error(`timeout checking ${url}`)); + }); + }); +} + +function checksumManifestUrl(assetUrl) { + return assetUrl.replace(/\/[^/]+$/, "/SHA256SUMS"); +} + +async function hasChecksumFor(url, asset) { + const sidecarStatus = await requestHead(`${url}.sha256`); + if (sidecarStatus >= 200 && sidecarStatus < 300) { + return true; + } + + const manifestUrl = checksumManifestUrl(url); + const manifest = await requestText(manifestUrl); + return manifest + .trim() + .split(/\r?\n/) + .some((line) => { + const parts = line.trim().split(/\s+/); + const hash = parts[0] && parts[0].toLowerCase(); + const name = parts.slice(1).join(" ").replace(/^\*/, ""); + return /^[a-f0-9]{64}$/.test(hash) && path.basename(name) === asset; + }); +} + +async function checkReleaseAssets() { + if (skipReleaseAssets || !releaseMode) { + return; + } + + const platformPath = path.join(packageRoot, "lib", "platform.js"); + if (!fs.existsSync(platformPath)) { + return; + } + + const platform = require(platformPath); + if (typeof platform.downloadUrl !== "function" || typeof platform.targetFor !== "function") { + return; + } + + for (const { osName, arch, target } of supportedTargets(platform)) { + const url = platform.downloadUrl(target); + const status = await requestHead(url); + assert(status >= 200 && status < 300, `release asset missing for ${osName}/${arch}: ${url} returned ${status}`); + assert(await hasChecksumFor(url, target.asset), `release checksum missing for ${osName}/${arch}: ${target.asset}`); + } +} + +async function main() { + checkSyncedFiles(); + checkMetadata(); + assertRuntimeScriptsDoNotEscapePackage(); + + const packageTempLabel = packageJson.name.replace(/[^A-Za-z0-9._-]/g, "-"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${packageTempLabel}-package-check-`)); + try { + const tarball = packTarball(tempDir); + checkPacklist(tarball); + checkTarballInstallSmoke(tarball, tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + await checkReleaseAssets(); + + if (failures.length > 0) { + for (const failure of failures) { + process.stderr.write(`package-check: ${failure}\n`); + } + process.exitCode = 1; + return; + } + + process.stdout.write(`package-check: ${packageJson.name} ok\n`); +} + +main().catch((error) => { + process.stderr.write(`package-check: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/synapse-rmcp/scripts/sync-readme.js b/packages/synapse-rmcp/scripts/sync-readme.js new file mode 100644 index 0000000..68b2e8a --- /dev/null +++ b/packages/synapse-rmcp/scripts/sync-readme.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); + +const packageReadme = path.resolve(__dirname, "..", "README.md"); +const repoReadme = path.resolve(__dirname, "..", "..", "..", "README.md"); +const packageRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(__dirname, "..", "..", ".."); + +if (fs.existsSync(packageReadme) && fs.lstatSync(packageReadme).isSymbolicLink()) { + fs.unlinkSync(packageReadme); +} + +fs.copyFileSync(repoReadme, packageReadme); + +for (const entry of fs.readdirSync(repoRoot)) { + const source = path.join(repoRoot, entry); + if (/^licen[cs]e/i.test(entry) && fs.statSync(source).isFile()) { + const destination = path.join(packageRoot, entry); + if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) { + fs.unlinkSync(destination); + } + fs.copyFileSync(source, destination); + } +} diff --git a/packages/synapse-rmcp/test/platform.test.js b/packages/synapse-rmcp/test/platform.test.js index beef5ca..4a25508 100644 --- a/packages/synapse-rmcp/test/platform.test.js +++ b/packages/synapse-rmcp/test/platform.test.js @@ -19,7 +19,7 @@ test("maps linux platform to release asset", () => { }); test("rejects unsupported platforms", () => { - assert.throws(() => targetFor("darwin", "arm64"), /Unsupported platform/); + assert.throws(() => targetFor("linux", "riscv64"), /Unsupported platform/); assert.throws(() => targetFor("win32", "x64"), /Unsupported platform/); }); diff --git a/plugins/synapse/.claude-plugin/plugin.json b/plugins/synapse/.claude-plugin/plugin.json index 2efa599..5991520 100644 --- a/plugins/synapse/.claude-plugin/plugin.json +++ b/plugins/synapse/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "synapse", - "description": "Connects Claude Code to the synapse MCP server — a Rust MCP + CLI server for Docker and SSH host management, exposing the flux (Docker/container/compose/host) and scout (SSH exec/filesystem/ZFS/logs) tools across configured infrastructure hosts.", + "description": "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer.", "author": { "name": "jmagar" }, - "repository": "https://github.com/jmagar/synapse", - "homepage": "https://github.com/jmagar/synapse", + "repository": "https://github.com/dinglebear-ai/synapse", + "homepage": "https://github.com/dinglebear-ai/synapse", "license": "MIT", "keywords": [ "synapse", diff --git a/plugins/synapse/.codex-plugin/plugin.json b/plugins/synapse/.codex-plugin/plugin.json index 2fbab52..5359228 100644 --- a/plugins/synapse/.codex-plugin/plugin.json +++ b/plugins/synapse/.codex-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "synapse", - "description": "MCP server for Docker and SSH host management — exposes flux (Docker/container/compose/host) and scout (SSH exec/filesystem/ZFS/logs) tools across configured infrastructure hosts.", - "homepage": "https://github.com/jmagar/synapse", - "repository": "https://github.com/jmagar/synapse", + "description": "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer.", + "homepage": "https://github.com/dinglebear-ai/synapse", + "repository": "https://github.com/dinglebear-ai/synapse", "license": "MIT", "keywords": [ "synapse", @@ -23,7 +23,7 @@ "Read", "Write" ], - "websiteURL": "https://github.com/jmagar/synapse", + "websiteURL": "https://github.com/dinglebear-ai/synapse", "defaultPrompt": [ "List the running Docker containers on my host and show recent logs for one of them.", "Check disk usage and ZFS pool health on my server over SSH.", diff --git a/plugins/synapse/.mcp.json b/plugins/synapse/.mcp.json new file mode 100644 index 0000000..6561583 --- /dev/null +++ b/plugins/synapse/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "synapse": { + "type": "http", + "url": "${user_config.server_url}/mcp", + "headers": { + "Authorization": "Bearer ${user_config.api_token}" + } + } + } +} diff --git a/plugins/synapse/gemini-extension.json b/plugins/synapse/gemini-extension.json index c964805..86c077f 100644 --- a/plugins/synapse/gemini-extension.json +++ b/plugins/synapse/gemini-extension.json @@ -1,8 +1,8 @@ { "name": "synapse", - "description": "MCP server for Docker and SSH host management — exposes flux (Docker/container/compose/host) and scout (SSH exec/filesystem/ZFS/logs) tools across configured infrastructure hosts.", + "description": "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer.", "author": "Jacob Magar", - "repository": "https://github.com/jmagar/synapse", + "repository": "https://github.com/dinglebear-ai/synapse", "license": "MIT", "keywords": ["synapse", "docker", "ssh", "infrastructure", "homelab", "mcp"], "contextFileName": "GEMINI.md", diff --git a/plugins/synapse/monitors/monitors.json b/plugins/synapse/monitors/monitors.json index fcd857b..be316dc 100644 --- a/plugins/synapse/monitors/monitors.json +++ b/plugins/synapse/monitors/monitors.json @@ -1,7 +1,7 @@ [ { "name": "server-health", - "command": "synapse watch --url \"${user_config.server_url}\" --interval 15", - "description": "MCP server health — emits a line when the server goes up, down, or recovers" + "command": "synapse watch --url ${user_config.server_url} --interval 15", + "description": "MCP server health \u2014 emits a line when the server goes up, down, or recovers" } ] diff --git a/release-please-config.json b/release-please-config.json index d78309e..231e8b2 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -63,8 +63,8 @@ ], "packages": { ".": { - "package-name": "synapse-rmcp", - "component": "synapse-rmcp", + "package-name": "@dinglebear/synapse", + "component": "@dinglebear/synapse", "changelog-path": "CHANGELOG.md", "extra-files": [ "packages/synapse-rmcp/package.json", @@ -81,7 +81,7 @@ { "type": "json", "path": "server.json", - "jsonpath": "$.packages[?(@.identifier == 'synapse-rmcp')].version" + "jsonpath": "$.packages[?(@.identifier == '@dinglebear/synapse')].version" }, { "type": "json", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8cf9c18..55df7f1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,11 @@ +# Toolchain used to BUILD synapse locally and in CI. Tracks the fleet's current +# Rust (1.97.1) so local builds match CI exactly. +# +# This is deliberately NOT the MSRV. The compatibility floor is Cargo.toml's +# `rust-version` and it is enforced separately by .github/workflows/msrv.yml, +# which pins its own older toolchain. Bumping this file does not change what +# synapse promises to compile on. [toolchain] -channel = "1.90" +channel = "1.97.1" components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/scripts/README.md b/scripts/README.md index d4bfc21..0761116 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -160,7 +160,7 @@ just schema-docs just schema-docs-check ``` -Treats `src/actions/operations.rs::OPERATION_SPECS` as canonical and verifies schema docs, help text, README, and plugin skill mentions. Generated output lives in `docs/MCP_SCHEMA.md`. +Treats `src/actions/operations.rs::OPERATION_SPECS` as canonical and verifies schema docs, help text, README, and plugin skill mentions. Generated output lives in `docs/MCP_SCHEMA.md`, including the required title and created/updated frontmatter used by the fleet documentation contract. ### `build-web.sh` diff --git a/scripts/check-schema-docs.py b/scripts/check-schema-docs.py index 046851a..6e5650e 100755 --- a/scripts/check-schema-docs.py +++ b/scripts/check-schema-docs.py @@ -107,6 +107,12 @@ def render() -> str: actions = extract_actions() scopes = extract_scope_for_actions() lines = [ + "---", + 'title: "synapse MCP Schema Contract"', + "created: 2026-08-01", + "updated: 2026-08-01", + "---", + "", "# synapse MCP Schema Contract", "", "`synapse` exposes two MCP tools: `flux` and `scout`.", diff --git a/scripts/kache-gate-selftest.sh b/scripts/kache-gate-selftest.sh new file mode 100755 index 0000000..d03693f --- /dev/null +++ b/scripts/kache-gate-selftest.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Proves scripts/kache-gate.sh actually rejects a degraded build, and that its +# baseline-and-diff scoping works. +# +# A gate that only ever passes is worse than no gate: it converts "the cache +# broke" into "CI is green". A gate that always FAILS is just as bad -- people +# learn to ignore it. Both directions are tested here. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +gate="$here/kache-gate.sh" + +fail() { echo "SELFTEST FAIL: $*" >&2; exit 1; } + +[ -x "$gate" ] || fail "gate script missing or not executable: $gate" + +probe="$(mktemp -d)" +trap 'rm -rf "$probe"' EXIT + +baseline="$probe/baseline.json" + +# Env assignments go in "$@" (before the script); script flags go in $GATE_ARGS +# (after it). Passing a flag through "$@" would make `env` consume it. +gate_run() { + set +e + env KACHE_CACHE_DIR="$probe/store" KACHE_GATE_ROOT="$probe/cold" \ + KACHE_GATE_BASELINE="$baseline" "$@" "$gate" ${GATE_ARGS:-} + local rc=$? + set -e + return "$rc" +} + +take_baseline() { GATE_ARGS=--baseline gate_run; } + +cargo new --lib --quiet "$probe/cold" >/dev/null + +# --- Snapshot BEFORE the build. The gate diffs against this, because +# `kache report --since` does not bound the event window in 0.12.0 and the +# summary counters are cumulative over the whole event log. +take_baseline >/dev/null || fail "--baseline failed" +[ -s "$baseline" ] || fail "--baseline wrote no snapshot" +echo "ok: baseline written" + +# --- A build into an ISOLATED, EMPTY store with NO remote: every unit is a +# cold miss. 0% hit rate, 0 remote hits -- a genuine degraded profile. +# KACHE_EVENT_ROOT pins the root stamped on events so --root matches exactly. +( + cd "$probe/cold" + KACHE_CACHE_DIR="$probe/store" KACHE_EVENT_ROOT="$probe/cold" cargo build --quiet +) + +echo "--- expect REJECT: 50% floor against an all-miss build ---" +if gate_run KACHE_GATE_MIN_HIT_RATE=50; then + fail "gate PASSED an all-miss build against a 50% floor" +fi +echo "ok: gate rejected the sub-floor hit rate" + +echo "--- expect REJECT: remote required but none configured ---" +if gate_run KACHE_GATE_MIN_HIT_RATE=0 KACHE_GATE_REQUIRE_REMOTE=1; then + fail "gate PASSED with REQUIRE_REMOTE=1 and zero remote hits" +fi +echo "ok: gate rejected zero remote hits" + +echo "--- expect ACCEPT: floor of 0, nothing required ---" +if ! gate_run KACHE_GATE_MIN_HIT_RATE=0; then + fail "gate REJECTED a build that violates no configured rule" +fi +echo "ok: gate accepted when no rule applies" + +# --- The regression that the pre-diff gate would have failed forever. +# Re-baseline at the CURRENT counters, then run the gate with no build in +# between. Whatever errors/misses the store has accumulated are now in the +# baseline, so every delta is zero and a strict gate must still pass. +echo "--- expect ACCEPT: historical errors, none added by this build ---" +take_baseline >/dev/null || fail "re-baseline failed" +if ! gate_run KACHE_GATE_MIN_HIT_RATE=90 KACHE_GATE_REQUIRE_REMOTE=1; then + fail "gate REJECTED a no-op build over counters that were already in the baseline" +fi +echo "ok: gate scoped to this build, not the whole event log" + +# --- Missing baseline must warn, not silently measure all history. +echo "--- expect WARNING: no baseline present ---" +rm -f "$baseline" +set +e +missing_out="$(gate_run KACHE_GATE_MIN_HIT_RATE=0 2>&1)" +set -e +case "$missing_out" in + *"no baseline"*) : ;; + *) fail "gate did not warn when the baseline was missing" ;; +esac +echo "ok: gate warned about the missing baseline" + +echo "SELFTEST PASS" diff --git a/scripts/kache-gate.sh b/scripts/kache-gate.sh new file mode 100755 index 0000000..bba3688 --- /dev/null +++ b/scripts/kache-gate.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# kache-gate.sh -- fail the build when the compiler cache silently degrades. +# +# WHY THIS EXISTS +# kache never fails a build on a cache problem (0.12.0 PR #600, "never fail a +# build on remote config"). A dead daemon, an unreachable remote, or a +# mis-normalized cache key all present as a green, slow build. soldr degraded +# exactly this way for a full day before anyone noticed. This gate converts +# those into a red job. +# +# WHY BASELINE-AND-DIFF INSTEAD OF `--since` +# `kache report --since` does NOT bound the event window in 0.12.0. Measured +# 2026-07-29 on soma: `--since 1m` and `--since 24h` returned counts differing +# by 2 out of 6,656, and a `--since 1m` query reported a timeline spanning 32 +# minutes. The summary counters are cumulative over the whole event log. +# +# Consequences if a gate trusted `--since`: +# * `errors > 0` goes permanently red after ONE historical store failure +# * `remote_hits > 0` passes forever after ONE historical remote hit +# * the hit-rate floor measures all history, not this build +# +# So the gate snapshots the cumulative counters BEFORE the build and diffs +# against them after. That is exact rather than time-windowed, and stays +# correct if upstream later fixes `--since`. +# +# USAGE +# kache-gate.sh --baseline # before the build: snapshot the counters +# kache-gate.sh # after the build: diff and enforce +# +# Environment: +# KACHE_GATE_BASELINE snapshot path (default $RUNNER_TEMP|/tmp /kache-gate-baseline.json) +# KACHE_GATE_MIN_HIT_RATE integer percent floor, on THIS build's delta (default 0) +# KACHE_GATE_REQUIRE_REMOTE 1 => require new remote hits this build (default 0) +# KACHE_GATE_REQUIRE_DAEMON 1 => require a reachable daemon (default 0) +# KACHE_GATE_ROOT build tree to scope to (default $PWD; --root DOES work) +# +# Exit: 0 pass / 1 gate violation / 2 report unusable +set -uo pipefail + +BASELINE="${KACHE_GATE_BASELINE:-${RUNNER_TEMP:-/tmp}/kache-gate-baseline.json}" +MIN_HIT_RATE="${KACHE_GATE_MIN_HIT_RATE:-0}" +REQUIRE_REMOTE="${KACHE_GATE_REQUIRE_REMOTE:-0}" +REQUIRE_DAEMON="${KACHE_GATE_REQUIRE_DAEMON:-0}" +ROOT="${KACHE_GATE_ROOT:-$PWD}" + +command -v kache >/dev/null 2>&1 || { echo "kache-gate: kache not on PATH" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "kache-gate: jq not on PATH" >&2; exit 2; } + +# Pull the cumulative counters. `--since 24h` is passed for forward +# compatibility only; it is currently ignored by kache (see header). +snapshot() { + kache report --format json --since 24h --root "$ROOT" 2>/dev/null \ + | jq -c '{ + local_hits: (.summary.local_hits // 0), + prefetch_hits: (.summary.prefetch_hits // 0), + remote_hits: (.summary.remote_hits // 0), + misses: (.summary.misses // 0), + errors: (.summary.errors // 0), + fallbacks: (.summary.fallbacks // 0), + total_crates: (.summary.total_crates // 0), + time_saved_ms: (.summary.time_saved_ms // 0) + }' +} + +if [ "${1:-}" = "--baseline" ]; then + snap="$(snapshot)" + if [ -z "$snap" ]; then + echo "kache-gate: could not read a baseline report" >&2 + exit 2 + fi + mkdir -p "$(dirname "$BASELINE")" + printf '%s\n' "$snap" > "$BASELINE" + echo "kache-gate: baseline written to $BASELINE" + printf '%s\n' "$snap" | jq -r '" cumulative before: hits=\(.local_hits + .prefetch_hits + .remote_hits) misses=\(.misses) errors=\(.errors)"' + exit 0 +fi + +after="$(snapshot)" +if [ -z "$after" ]; then + echo "kache-gate: could not read a usable JSON report" >&2 + exit 2 +fi + +if [ -r "$BASELINE" ]; then + before="$(cat "$BASELINE")" + scope="this build" +else + # No baseline: fall back to absolute counters and say so loudly, because + # cumulative counters make every threshold measure all history. + before='{"local_hits":0,"prefetch_hits":0,"remote_hits":0,"misses":0,"errors":0,"fallbacks":0,"total_crates":0,"time_saved_ms":0}' + scope="ALL HISTORY (no baseline at $BASELINE)" + echo "kache-gate: WARNING: no baseline found -- thresholds will measure the whole event log," >&2 + echo "kache-gate: WARNING: not this build. Run 'kache-gate.sh --baseline' before the build." >&2 +fi + +read -r d_local d_prefetch d_remote d_miss d_err d_fall d_total d_saved <}" +printf ' %s\n' "${remote_line:-Remote: }" +echo " thresholds min_hit_rate=${MIN_HIT_RATE} require_remote=${REQUIRE_REMOTE} require_daemon=${REQUIRE_DAEMON}" +echo "------------------------------------------------------------" + +violations=0 +violation() { echo "kache-gate: VIOLATION: $*" >&2; violations=$((violations + 1)); } + +# Store-level failures introduced BY THIS BUILD. Non-zero means artifacts were +# produced and lost. +[ "$d_err" -gt 0 ] && violation "$d_err compile(s) failed to store" +[ "$d_fall" -gt 0 ] && violation "$d_fall compile(s) fell back to another wrapper" + +# A build that compiled nothing cacheable cannot be judged on hit rate. +if [ "$cacheable" -eq 0 ]; then + echo "kache-gate: no cacheable compiles in this build -- skipping hit-rate and remote checks" +else + floor_ok="$(awk -v r="$hit_rate" -v m="$MIN_HIT_RATE" 'BEGIN { print (int(r) >= int(m)) ? 1 : 0 }')" + [ "$floor_ok" = "1" ] || violation "hit rate ${hit_rate}% is below the ${MIN_HIT_RATE}% floor" + + # A configured remote that serves no NEW hit is the silent-degradation case. + if [ "$REQUIRE_REMOTE" = "1" ] && [ "$d_remote" -eq 0 ]; then + violation "remote hits required but none occurred this build (${remote_line:-no Remote line})" + fi +fi + +# The daemon is the only path that uploads dependency artifacts and the only +# path that performs remote lookups. Down => this runner neither reads nor +# contributes, and says nothing about it. +if [ "$REQUIRE_DAEMON" = "1" ]; then + case "$daemon_line" in + *"not reachable"*|"") violation "daemon is not reachable (${daemon_line:-no Daemon line})" ;; + esac +fi + +if [ "$violations" -gt 0 ]; then + echo "kache-gate: FAILED with $violations violation(s)" >&2 + exit 1 +fi +echo "kache-gate: OK" +exit 0 diff --git a/scripts/pre-release-check.sh b/scripts/pre-release-check.sh index 980b0aa..9f96b01 100755 --- a/scripts/pre-release-check.sh +++ b/scripts/pre-release-check.sh @@ -54,8 +54,8 @@ run_packaging_identity_check() { printf 'release workflow must package the synapse binary\n' >&2 failed=1 } - grep -q 'IMAGE_NAME: ghcr.io/jmagar/synapse' .github/workflows/docker-publish.yml || { - printf 'Docker workflow must publish ghcr.io/jmagar/synapse\n' >&2 + grep -q 'IMAGE_NAME: ghcr.io/dinglebear-ai/synapse' .github/workflows/docker-publish.yml || { + printf 'Docker workflow must publish ghcr.io/dinglebear-ai/synapse\n' >&2 failed=1 } grep -Fq 'image-ref: ${{ fromJSON(steps.meta.outputs.json).tags[0] }}' .github/workflows/docker-publish.yml || { diff --git a/server.json b/server.json index 41abc3e..168bcc1 100644 --- a/server.json +++ b/server.json @@ -1,12 +1,12 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "ai.dinglebear/synapse-rmcp", + "name": "ai.dinglebear/synapse", "title": "Synapse RMCP", - "description": "Rust MCP and CLI server for Docker, host inspection, SSH, logs, ZFS, and safe file operations.", + "description": "MCP server and CLI for host and container operations: Docker and Compose control, SSH, host inspection, logs, ZFS, and safe file transfer.", "version": "1.0.0", - "websiteUrl": "https://github.com/jmagar/synapse", + "websiteUrl": "https://github.com/dinglebear-ai/synapse", "repository": { - "url": "https://github.com/jmagar/synapse", + "url": "https://github.com/dinglebear-ai/synapse", "source": "github", "id": "1247261737" }, @@ -24,7 +24,7 @@ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", - "identifier": "synapse-rmcp", + "identifier": "@dinglebear/synapse", "version": "1.0.0", "runtimeHint": "npx", "packageArguments": [ @@ -67,7 +67,7 @@ "format": "string", "isRequired": false, "isSecret": false, - "placeholder": "v0.5.3" + "placeholder": "v1.0.0" }, { "name": "SYNAPSE_RMCP_REPO", @@ -75,8 +75,8 @@ "format": "string", "isRequired": false, "isSecret": false, - "default": "jmagar/synapse", - "placeholder": "jmagar/synapse" + "default": "dinglebear-ai/synapse", + "placeholder": "dinglebear-ai/synapse" }, { "name": "SYNAPSE_RMCP_RELEASE_BASE_URL", @@ -105,12 +105,12 @@ "namespace": "ai.dinglebear", "dnsDomain": "dinglebear.ai", "distribution": { - "npm": "synapse-rmcp@1.0.0", - "nodePackage": "synapse-rmcp" + "npm": "@dinglebear/synapse@1.0.0", + "nodePackage": "@dinglebear/synapse" }, "buildInfo": { "version": "1.0.0", - "repository": "https://github.com/jmagar/synapse" + "repository": "https://github.com/dinglebear-ai/synapse" } } } diff --git a/src/app.rs b/src/app.rs index 6ebd397..ccafc65 100644 --- a/src/app.rs +++ b/src/app.rs @@ -51,7 +51,7 @@ impl SynapseService { /// The host repository resolves the real host topology (`SYNAPSE_HOSTS_CONFIG` /// → `SYNAPSE_CONFIG_FILE` → `~/.ssh/config`) shared by both flux and scout. /// - /// A single [`SshPool`] is threaded from flux through to the scout service and + /// A single `SshPool` is threaded from flux through to the scout service and /// the Docker client cache, so all three consumers share ControlMaster /// connections rather than opening independent pools (`C-1`/`P-C1`). pub fn new() -> Self { diff --git a/src/docker_client.rs b/src/docker_client.rs index 82eef43..f33d901 100644 --- a/src/docker_client.rs +++ b/src/docker_client.rs @@ -13,7 +13,7 @@ //! - **`bollard::Docker` is cheap to Clone** (internally `Arc` + //! `Arc`), so each cache entry holds it **by value** inside a //! [`BollardClient`] bundle. For remote hosts the bundle also owns the -//! [`ForwardedSocket`] + `Arc` that keep the unix socket alive; +//! `ForwardedSocket` + `Arc` that keep the unix socket alive; //! bollard's `Docker` is only valid while that forward lives, so the bundle is //! the unit of caching (handed out as `Arc`). //! - **Per-host cache keyed by `HostConfig.name`.** One `BollardClient` per diff --git a/src/fanout.rs b/src/fanout.rs index f047f4c..e440da3 100644 --- a/src/fanout.rs +++ b/src/fanout.rs @@ -162,7 +162,7 @@ impl FanoutOutcome { /// /// # Concurrency model /// -/// Uses [`FuturesUnordered`] + [`Arc`]. The semaphore permit is +/// Uses `FuturesUnordered` + [`Arc`]. The semaphore permit is /// acquired **inside** each future (not before pushing to the set) to prevent /// deadlocks under load. /// diff --git a/src/flux_service/container_lifecycle.rs b/src/flux_service/container_lifecycle.rs index dde6a47..de8bb96 100644 --- a/src/flux_service/container_lifecycle.rs +++ b/src/flux_service/container_lifecycle.rs @@ -5,7 +5,7 @@ //! //! The **pure** per-host functions in this module operate on `&dyn ContainerOps` //! (and `&dyn ImageOps`) so they are fully unit-testable with -//! [`MockDockerClient`](crate::docker_client::MockDockerClient) — no live docker +//! `MockDockerClient` — no live docker //! daemon required. //! //! # Destructive gating (B5) diff --git a/src/flux_service/container_read.rs b/src/flux_service/container_read.rs index 420ba10..85aad12 100644 --- a/src/flux_service/container_read.rs +++ b/src/flux_service/container_read.rs @@ -5,7 +5,7 @@ //! //! The **pure** per-host functions in this module operate on //! `&dyn ContainerOps` so they are fully unit-testable with -//! [`MockDockerClient`](crate::docker_client::MockDockerClient) — no live docker +//! `MockDockerClient` — no live docker //! daemon required. [`FluxService`](super::FluxService) resolves hosts, acquires //! the cached bollard client, and drives the fanout; it then calls these pure //! functions per host. diff --git a/src/flux_service/docker.rs b/src/flux_service/docker.rs index d45585b..d46be69 100644 --- a/src/flux_service/docker.rs +++ b/src/flux_service/docker.rs @@ -5,7 +5,7 @@ //! //! Mirrors [`container_read`](super::container_read): the **pure** per-host //! functions here operate on the segregated `&dyn …Ops` trait objects so they are -//! fully unit-testable with [`MockDockerClient`](crate::docker_client::MockDockerClient). +//! fully unit-testable with `MockDockerClient`. //! [`FluxService`](super::FluxService) resolves hosts, acquires the cached bollard //! client, drives fanout for the read-only ops, and enforces the destructive gate //! for `pull`/`build`/`rmi`/`prune` **before** calling these functions. diff --git a/src/flux_service/host.rs b/src/flux_service/host.rs index e873f67..66d96c3 100644 --- a/src/flux_service/host.rs +++ b/src/flux_service/host.rs @@ -4,7 +4,7 @@ //! # Architecture seam //! //! Pure per-host functions here take `&dyn HostExec` — a thin seam over either -//! `std::process::Command` (local) or `SshExecutor` (remote). [`FluxService`] +//! `std::process::Command` (local) or `SshExecutor` (remote). `FluxService` //! routes local vs. SSH, resolves hosts, drives fanout, and calls these fns. //! //! # Command strategy diff --git a/src/formatters/scout.rs b/src/formatters/scout.rs index 2d2e16c..9c2f727 100644 --- a/src/formatters/scout.rs +++ b/src/formatters/scout.rs @@ -34,7 +34,7 @@ use crate::formatters::{format_bytes, format_timestamp, str_field}; /// | squirts | /// | boops | /// | nicks | -/// ``` +/// ```text pub fn render_scout_nodes_markdown(data: &Value) -> String { let hosts: Vec = if let Some(arr) = data.get("hosts").and_then(|v| v.as_array()) { arr.iter() @@ -81,11 +81,8 @@ pub fn render_scout_nodes_markdown(data: &Value) -> String { /// ```text /// File Read: squirts:/etc/hostname /// Size: 8 B | truncated: no -/// -/// ``` /// squirts /// ``` -/// ``` pub fn render_scout_peek_markdown(data: &Value) -> String { let host = str_field(data, "host"); let path = str_field(data, "path"); @@ -140,10 +137,8 @@ pub fn render_scout_peek_markdown(data: &Value) -> String { /// **Exit:** 0 /// /// **Output:** -/// ``` /// 15:23:45 up 3 days /// ``` -/// ``` pub fn render_scout_exec_markdown(data: &Value) -> String { let host = str_field(data, "host"); let path = str_field(data, "path"); diff --git a/src/logging.rs b/src/logging.rs index 8185bb5..98c7243 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -311,7 +311,7 @@ fn rotate_log(state: &mut RotatingLogState) -> std::io::Result<()> { /// /// Priority order (highest to lowest): /// -/// 1. `NO_COLOR` env var set → **no color** (https://no-color.org convention) +/// 1. `NO_COLOR` env var set → **no color** ( convention) /// 2. `FORCE_COLOR` env var set → **force color** (useful in Docker/CI) /// 3. `stderr` is a TTY → **color** (interactive terminal) /// 4. `stderr` is not a TTY → **no color** (piped/redirected) diff --git a/src/mcp/rmcp_server.rs b/src/mcp/rmcp_server.rs index e5fdf3c..e372de4 100644 --- a/src/mcp/rmcp_server.rs +++ b/src/mcp/rmcp_server.rs @@ -14,10 +14,10 @@ use lab_auth::AuthContext; use rmcp::{ ErrorData, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResult, GetPromptRequestParams, GetPromptResult, - Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult, - PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, - ServerInfo, Tool, + CallToolRequestParams, CallToolResponse, CallToolResult, GetPromptRequestParams, + GetPromptResponse, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, ServerCapabilities, ServerInfo, Tool, }, service::{Peer, RequestContext}, }; @@ -72,7 +72,7 @@ impl ServerHandler for SynapseRmcpServer { &self, request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { let tool_name = request.name.to_string(); let arguments = request .arguments @@ -175,78 +175,80 @@ impl ServerHandler for SynapseRmcpServer { tracing::info!(tool = %tool_name, action = %action, "MCP tool execution started"); let render_args = arguments.clone(); - match execute_tool(&self.state, &tool_name, arguments, &peer).await { - Ok(result) => { - tracing::info!( - tool = %tool_name, - elapsed_ms = started.elapsed().as_millis(), - "MCP tool execution completed" - ); - let text = match render_mcp_tool_output(&tool_name, &render_args, &result) { - Ok(text) => text, - Err(error) => { - self.state.activity.record( - "mcp", - &activity_action, - false, - Some("execution failed"), - ); - return Err(ErrorData::internal_error( - format!("render error: {error}"), - None, - )); - } - }; - let result = tool_result_from_text(text); - self.state - .activity - .record("mcp", &activity_action, result.is_ok(), None); - result - } - Err(error) if crate::actions::is_confirmation_denied(&error) => { - self.state.activity.record( - "mcp", - &activity_action, - false, - Some("confirmation denied"), - ); - tracing::warn!( - tool = %tool_name, - elapsed_ms = started.elapsed().as_millis(), - "MCP tool destructive op not confirmed" - ); - Err(ErrorData::invalid_request(error.to_string(), None)) - } - Err(error) if crate::actions::is_validation_error(&error) => { - self.state.activity.record( - "mcp", - &activity_action, - false, - Some(&error.to_string()), - ); - tracing::warn!( - tool = %tool_name, - elapsed_ms = started.elapsed().as_millis(), - "MCP tool rejected invalid params" - ); - Err(ErrorData::invalid_params(error.to_string(), None)) - } - Err(error) => { - self.state.activity.record( - "mcp", - &activity_action, - false, - Some(&error.to_string()), - ); - tracing::error!( - tool = %tool_name, - elapsed_ms = started.elapsed().as_millis(), - error = %error, - "MCP tool execution failed" - ); - Ok(tool_error_result(&activity_action, &error.to_string())) - } - } + let outcome: Result = + match execute_tool(&self.state, &tool_name, arguments, &peer).await { + Ok(result) => { + tracing::info!( + tool = %tool_name, + elapsed_ms = started.elapsed().as_millis(), + "MCP tool execution completed" + ); + let text = match render_mcp_tool_output(&tool_name, &render_args, &result) { + Ok(text) => text, + Err(error) => { + self.state.activity.record( + "mcp", + &activity_action, + false, + Some("execution failed"), + ); + return Err(ErrorData::internal_error( + format!("render error: {error}"), + None, + )); + } + }; + let result = tool_result_from_text(text); + self.state + .activity + .record("mcp", &activity_action, result.is_ok(), None); + result + } + Err(error) if crate::actions::is_confirmation_denied(&error) => { + self.state.activity.record( + "mcp", + &activity_action, + false, + Some("confirmation denied"), + ); + tracing::warn!( + tool = %tool_name, + elapsed_ms = started.elapsed().as_millis(), + "MCP tool destructive op not confirmed" + ); + Err(ErrorData::invalid_request(error.to_string(), None)) + } + Err(error) if crate::actions::is_validation_error(&error) => { + self.state.activity.record( + "mcp", + &activity_action, + false, + Some(&error.to_string()), + ); + tracing::warn!( + tool = %tool_name, + elapsed_ms = started.elapsed().as_millis(), + "MCP tool rejected invalid params" + ); + Err(ErrorData::invalid_params(error.to_string(), None)) + } + Err(error) => { + self.state.activity.record( + "mcp", + &activity_action, + false, + Some(&error.to_string()), + ); + tracing::error!( + tool = %tool_name, + elapsed_ms = started.elapsed().as_millis(), + error = %error, + "MCP tool execution failed" + ); + Ok(tool_error_result(&activity_action, &error.to_string())) + } + }; + outcome.map(Into::into) } // ── resources ───────────────────────────────────────────────────────────── @@ -267,7 +269,7 @@ impl ServerHandler for SynapseRmcpServer { &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { let auth = require_auth_context(&self.state, &context)?; if resources::requires_read_scope(&request.uri) && let Some(auth) = auth @@ -283,7 +285,7 @@ impl ServerHandler for SynapseRmcpServer { ErrorData::internal_error(format!("resource read failed: {e}"), None) } })?; - Ok(ReadResourceResult::new(vec![contents])) + Ok(ReadResourceResult::new(vec![contents]).into()) } // ── prompts ─────────────────────────────────────────────────────────────── @@ -301,9 +303,11 @@ impl ServerHandler for SynapseRmcpServer { &self, request: GetPromptRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { require_auth_context(&self.state, &context)?; - prompts::get_prompt(request).map_err(|e| ErrorData::invalid_params(e.to_string(), None)) + prompts::get_prompt(request) + .map(Into::into) + .map_err(|e| ErrorData::invalid_params(e.to_string(), None)) } // ── server info ─────────────────────────────────────────────────────────── diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 9112bf0..31ea389 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -22,7 +22,7 @@ use super::rmcp_server::{SynapseRmcpServer, rmcp_server as make_server}; pub fn streamable_http_config(config: &McpConfig) -> StreamableHttpServerConfig { StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_allowed_hosts(allowed_hosts(config)) .with_allowed_origins(allowed_origins(config)) diff --git a/src/ssh.rs b/src/ssh.rs index 9ebd9f9..5e7e14d 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -1,6 +1,6 @@ //! SSH transport layer for synapse. //! -//! Provides an [`SshSession`] abstraction over the `openssh` crate covering +//! Provides an `SshSession` abstraction over the `openssh` crate covering //! connection lifecycle, command execution, and unix-socket forwarding. This is //! the bedrock for every remote operation: `scout` (remote exec/peek) and //! `flux` (remote docker via forwarded socket). @@ -16,7 +16,7 @@ //! hang modes). //! - **One `Arc` per host.** openssh ControlMaster multiplexes, so a //! pool of N control sockets gives no concurrency benefit. A single session -//! is shared by all callers; a per-host [`Semaphore`] (default 8) caps +//! is shared by all callers; a per-host `Semaphore` (default 8) caps //! concurrent `command()` invocations. //! - **Passive health.** Sessions are marked dead on command failure and lazily //! reconnected on next checkout. A background task evicts sessions idle > 5 diff --git a/src/ssh/pool.rs b/src/ssh/pool.rs index b680105..771e362 100644 --- a/src/ssh/pool.rs +++ b/src/ssh/pool.rs @@ -44,7 +44,7 @@ fn instant_to_nanos(t: Instant) -> u64 { /// A pooled SSH session: one multiplexed `openssh::Session` plus the per-host /// exec semaphore and a last-activity timestamp for idle eviction. /// -/// `last_used_nanos` stores nanoseconds since [`instant_epoch`] as an +/// `last_used_nanos` stores nanoseconds since `instant_epoch` as an /// [`AtomicU64`], replacing the former `std::sync::Mutex`. This is /// lock-free and avoids mutex contention in async contexts (A-M4 / P-M5). pub struct PooledSession { diff --git a/tests/plugin_contract.rs b/tests/plugin_contract.rs index 482cf6c..aa2895d 100644 --- a/tests/plugin_contract.rs +++ b/tests/plugin_contract.rs @@ -21,7 +21,7 @@ fn plugin_manifests_exist_for_all_supported_hosts() { "plugins/synapse/.claude-plugin/plugin.json", "plugins/synapse/.codex-plugin/plugin.json", "plugins/synapse/gemini-extension.json", - "plugins/synapse/mcp.json", + "plugins/synapse/.mcp.json", "plugins/synapse/monitors/monitors.json", "plugins/synapse/skills/synapse/SKILL.md", ] { @@ -34,7 +34,7 @@ fn plugin_manifests_share_identity_and_connection_settings() { let claude = json("plugins/synapse/.claude-plugin/plugin.json"); let codex = json("plugins/synapse/.codex-plugin/plugin.json"); let gemini = json("plugins/synapse/gemini-extension.json"); - let mcp = json("plugins/synapse/mcp.json"); + let mcp = json("plugins/synapse/.mcp.json"); assert_eq!(claude["name"], "synapse"); assert_eq!(codex["name"], "synapse"); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 634c13c..06ca977 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,11 +13,18 @@ [package] name = "xtask" -version = "1.0.0" -edition = "2021" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true # Exclude from crates.io — this is a private automation crate. publish = false +[lints] +workspace = true + [dependencies] # TEMPLATE: anyhow for ergonomic errors, walkdir for filesystem traversal. # These are the only deps needed for the four required commands. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 61284a8..d8a5685 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -17,7 +17,7 @@ //! and use `std::process::Command` to shell out to existing tools rather than //! reimplementing them in Rust. -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::process::{Command, Stdio}; use walkdir::WalkDir; @@ -408,7 +408,7 @@ fn symlink_docs() -> Result<()> { /// Run this to get a clear error message before starting the server, rather /// than a cryptic runtime failure. /// -/// TEMPLATE: Replace the variable names in REQUIRED_VARS with your service's +/// TEMPLATE: Replace the variable names in `REQUIRED_VARS` with your service's /// actual required environment variables. /// /// Variables listed as "optional" are checked for presence but not required — diff --git a/xtask/src/patterns.rs b/xtask/src/patterns.rs index ffc0396..9efc0ec 100644 --- a/xtask/src/patterns.rs +++ b/xtask/src/patterns.rs @@ -6,7 +6,7 @@ mod reporter; mod surfaces; mod util; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use reporter::PatternReporter;