Skip to content

Implementation Plan: 003 — MCP Gateway Desktop

Branch: feature/003-mcp-gateway-desktopSpec: ./spec.mdStatus: Accepted

Summary

Wrap the 002 web UI in a Tauri 2 desktop shell, supervise the headless daemon via ADR-006's detect-or-spawn pattern, deploy the bundled coffer-mcp-shim to a stable user-writable PATH location on every launch, and ship the result as two download tiers built from the same PyInstaller binaries, shipped for macOS arm64 only: a CLI-only coffer-cli-<triple>.tar.gz archive (coffer + coffer-daemon + coffer-mcp-shim, for headless installs) and a CLI+desktop Tauri bundle (unsigned macOS arm64 .dmg). The PyInstaller-built daemon + shim binaries are carried inside the Tauri bundle as bundle.externalBin sidecars per ADR-008.

This spec adds no new backend, no new resource kinds, and no new UI screens.

See ./spec.md for the user-visible contract, ./quickstart.md for the end-user walkthrough, and ADR-008 for the distribution-architecture decision.

Technical Context

DimensionValue
Language / VersionRust 1.78+ (Tauri 2 crate); reuses Python 3.12 from the daemon / shim (PyInstaller-bundled).
Primary DependenciesTauri 2 (tauri, tauri-build); tauri-plugin-shell only as needed for sidecar spawn; the 002 frontend bundle (loaded from dist/).
Sidecar Binariescoffer-daemon and coffer-mcp-shim, built per platform via scripts/build_binaries.sh driving the PyInstaller specs in backend/.
Daemon Discovery~/.coffer/daemon.json (port + token + pid), shared with the CLI and shim. See ADR-006.
Shim PATH targetmacOS / Linux: ~/.coffer/bin/. Windows: %LOCALAPPDATA%\Coffer\bin\ (fallback %USERPROFILE%\Coffer\bin\ when unset).
TestingRust unit tests (#[cfg(test)]) for shim-deploy, daemon-supervisor, and tray-handler logic; Playwright (e2e/) for tray + window scenarios when running under make dev-tauri; CI smoke test (scripts/smoke_test_bundle.sh) for the bundled artifact.
Target PlatformsmacOS 12+ arm64 (Apple Silicon) only — one unsigned .dmg. macOS x64, Linux, and Windows are not built (see Build matrix).
Project TypeNative desktop shell wrapping a SPA. The Tauri crate lives in desktop/; the frontend bundle is frontend/dist/ (built by 002's Vite pipeline).
Performance GoalsCold-start < 3 s with no daemon running, < 1 s with daemon already running (SC-D01). PyInstaller sidecars carry the cold-start floor.
ConstraintsLocal-first (loopback-only daemon); no public-internet calls; no analytics. Each bundle file ≤ 200 MB (SC-D02). No new constitutional dependencies — all picks were already approved by ADR-006 / ADR-008.
Scale / ScopeSingle-user desktop; one daemon process; one shim per MCP client; ≤ 30 registered resources (consistent with 001 / 002 limits).

Constitution Check

Constitutional clauseComplianceNotes
I. Local-First (NON-NEGOTIABLE)OKDaemon stays loopback-only; the desktop shell never reaches the public internet; no telemetry or update-server call ships.
II. Spec-as-TruthOKSpec committed before code; every acceptance scenario has a covering test (acceptance audit).
III. Open-Source-ReadinessOKTauri 2 (MIT/Apache-2.0) is permissive-licensed; PyInstaller is GPL with a runtime-only exception that does not contaminate bundled binaries.
LanguagesOKRust is allowed for the desktop shell per the constitution Languages clause; the daemon and shim remain Python 3.12.
Architecture: layeredOKDesktop crate is a thin shell — supervision, tray, and shim deploy live in dedicated modules; the web UI from 002 is the view layer.
Persistence: SQLite for control planeOKThis spec owns no persistence; the daemon does. The shell reads ~/.coffer/daemon.json (not SQLite) to discover the daemon — the discovery file is part of the runtime contract owned by ADR-006.
Credentials: encrypted storeOKSpec owns no credentials.
Network defaults: loopback-onlyOKThe shell talks to the daemon on 127.0.0.1:<port> from daemon.json; no other HTTP origin is reached.

Project Structure

Documentation (this feature)

text
specs/003-mcp-gateway-desktop/
├── spec.md           # user-visible contract (committed)
├── plan.md           # this file
└── quickstart.md     # end-user walkthrough (download → install → first launch → tray)

This folder deliberately has no data-model.md (no backend data here) and no tasks.md (the work is tracked at the user-story / PR level — the unit of change is "one desktop concern" — shim deploy, tray, release pipeline — and each lands as one PR).

Source code (delivered in this PR)

text
desktop/
├── Cargo.toml                        # tauri 2 crate
├── tauri.conf.json                   # window config, bundle.externalBin sidecars, macOS signingIdentity stub
├── icons/                            # platform tray + app icons + embedded fallback PNG
├── binaries/                         # PyInstaller output landing zone (gitignored; populated in CI)
├── src/
│   ├── main.rs                       # entry — tauri::Builder
│   ├── lib.rs                        # app setup, tray menu wiring, window-close interceptor
│   ├── daemon.rs                     # detect-or-spawn helper (POSIX setsid / Windows DETACHED_PROCESS)
│   ├── shim.rs                       # idempotent shim copy with size-mismatch heuristic
│   └── tray.rs                       # tray menu (Open / Restart daemon / Quit)
└── tests/                            # rust unit tests for the above modules

scripts/
├── build_binaries.sh                 # drives PyInstaller specs in backend/ (added in this PR)
└── smoke_test_bundle.sh              # CI post-build smoke test (added in this PR)

.github/workflows/
└── release.yml                       # macOS-arm64-only release matrix + aggregated SHA256SUMS

Extension point: the daemon-supervisor module

desktop/src/daemon.rs is the Rust-side mirror of the Python detect-or-spawn helper used by the CLI and shim. It reads ~/.coffer/daemon.json, probes the recorded PID, and spawns coffer-daemon as a detached process when no live daemon is found. "Detached" means setsid on POSIX and CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS on Windows — this is what makes the daemon outlive the desktop window's close-to-tray.

Shim deploy strategy

desktop/src/shim.rs runs on every desktop launch:

  1. Resolve the target directory (~/.coffer/bin/ on macOS/Linux, %LOCALAPPDATA%\Coffer\bin\ on Windows, falling back to %USERPROFILE%\Coffer\bin\ when %LOCALAPPDATA% is unset).
  2. Probe the bundle for the coffer-mcp-shim sidecar binary. The probe checks the sidecar's expected location first, then walks one directory up — necessary because Tauri places sidecars in different relative locations under target/debug/ (dev) and Resources/ (release).
  3. Compare the bundled shim's byte size to the on-disk shim (when present). Equal → no-op. Differing → atomic replace (tempfile + rename).

The PATH-membership prompt is shown once on first launch when the target directory is not on PATH; the shell just deploys the binary.

Build matrix

The build matrix ships macOS arm64 only. It runs PyInstaller once, then emits two download tiers from the same binaries: the CLI-only archive and the desktop installer.

PlatformArchitectureCLI-only archiveDesktop installerBuild host
macOSarm64coffer-cli-<triple>.tar.gz.dmg (unsigned)macos-14 runner

<triple> is the build triple aarch64-apple-darwin. The CLI-only archive holds coffer, coffer-daemon, and coffer-mcp-shim. The DMG and the zipped .app are renamed *-unsigned because no code-signing / notarisation is wired. A single aggregated SHA256SUMS file (not per-artifact .sha256 siblings) covers every artifact.

macOS x64 (Intel) is deliberately not built — the Intel runner pool is being deprecated and reliably starves the job, and PyInstaller can't cross-compile the x86_64 sidecars from the arm64 runner. Linux and Windows bundles are not shipped — those legs were never validated. The acceptance matrix asserts a macOS-arm64-only build. Code-signing / notarisation is added in a separate signed-release workflow once a paid Apple Developer ID is provisioned (see docs/distribution/macos-notarization.md).

File size caps

  • Each Tauri bundle ≤ 200 MB (SC-D02). Current observed: 90–150 MB.
  • Each PyInstaller sidecar ≤ 100 MB. Current observed: daemon ~70 MB, shim ~30 MB.
  • Rust source files in desktop/src/ ≤ 250 LOC each (per scripts/check_file_sizes.py).

Phases (high-level)

Phases are delivery boundaries, not atomic-task breakdowns.

Phase 1 — Tauri shell + frontend mount

tauri.conf.json (window, sidecars, icons), desktop/src/main.rs + lib.rs, Vite frontend mount.

Done when: cargo tauri dev launches the 002 web UI inside the Tauri window; the isTauri() guard is true on first render.

Phase 2 — Daemon supervisor

daemon.rs — detect-or-spawn against ~/.coffer/daemon.json, detached spawn (setsid / DETACHED_PROCESS).

Done when: the shell connects to an already-running daemon without duplicating, spawns one when none is running, and the spawned daemon survives desktop-app close (US3 scenarios).

Phase 3 — Tray menu + close-to-tray

tray.rs (Open / Restart daemon / Quit), window-close interceptor in lib.rs.

Done when: US5's tray scenarios pass and US1's "close to tray, not exit" scenario passes.

Phase 4 — Shim auto-deploy

shim.rs — idempotent copy with size-mismatch heuristic, parent-dir probing, Windows PATH fallback.

Done when: US4's three scenarios pass.

Phase 5 — Release pipeline + smoke test

.github/workflows/release.yml runs the macOS-arm64 build leg; it packages the three binaries (coffer, coffer-daemon, coffer-mcp-shim) into a coffer-cli-<triple>.tar.gz archive and builds the unsigned desktop bundle; every artifact is covered by one aggregated SHA256SUMS file; scripts/smoke_test_bundle.sh runs against the bundle.

Done when: US2's two build-pipeline scenarios pass and a draft release on a v*-rc tag produces the CLI-only archive and the macOS-arm64 desktop bundle green.

Complexity Tracking

DecisionWhy neededSimpler alternative rejected because
Tauri 2 over ElectronSmaller bundle (no embedded Chromium), native OS controls, Rust core — fits Coffer's "local-first lightweight" posture.Electron would inflate each installer by ~150 MB on top of the PyInstaller sidecars and pull in a full Node runtime; the visual-language polish from 002 doesn't need a Chromium-specific feature.
Detached daemon spawn (setsid / DETACHED_PROCESS)Required for the daemon to outlive the desktop window's close-to-tray — otherwise OS process tree cleanup kills the daemon when the Tauri process exits.A foreground child process leaks the close-to-tray scenario; reparenting to PID 1 is what setsid and DETACHED_PROCESS are for.
Shim deploy on every launch (idempotent)The desktop shell is the only entry point that knows where the bundled shim lives; deploying once-only (at install time) would miss in-place bundle upgrades.A separate "shim updater" service would be more moving parts; the size-mismatch heuristic is a few lines and runs in microseconds when the binary is already up to date.
macOS arm64 only (no x64 / Linux / Windows legs)The Intel runner pool is being deprecated and starves the job, PyInstaller can't cross-compile x86_64 sidecars from arm64, and the Linux/Windows legs were never validated.Shipping unvalidated cross-platform bundles would mean publishing artifacts no one tested; the matrix is intentionally scoped to the one validated target until the others are wired and proven.

Cross-Reference Index