From f23f2f2b30cea2b4462397046bad17cbf3bf4095 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Sun, 20 Sep 2026 07:40:21 -0400 Subject: [PATCH] Serve MCP over HTTPS for the Claude Desktop connector Claude Desktop's custom connector only accepts https URLs. Added an optional TLS listener (HTTPS_ADDR + TLS_CERT_FILE/TLS_KEY_FILE) alongside HTTP; docker-compose publishes 127.0.0.1:8443 and mounts a local mkcert cert from certs/ (git-ignored). Best-effort: a missing cert logs a warning and stays HTTP-only. Verified the Windows store trusts the mkcert cert and MCP initialize succeeds over https://127.0.0.1:8443/mcp. Co-Authored-By: Claude Opus 4.8 --- .env.example | 14 ++++++++++++++ .gitignore | 3 +++ AGENT.md | 15 ++++++++++++--- CHANGELOG.md | 15 +++++++++++++++ cmd/server/main.go | 16 ++++++++++++++++ docker-compose.yml | 7 +++++++ internal/config/config.go | 12 +++++++++++- 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index e370c12..0681b3b 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,20 @@ # authentication (AGENT.md §0). Only bind to a non-local interface deliberately. LISTEN_ADDR=127.0.0.1:8080 +# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only +# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS +# listener starts alongside HTTP. docker-compose sets these to the mounted certs. +# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA +# your OS — and Claude Desktop — will trust), from the project root: +# winget install FiloSottile.mkcert +# mkcert -install # trust step (adds the local CA) +# mkdir certs +# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1 +# Then connect Claude Desktop to https://localhost:8443/mcp +HTTPS_ADDR= +TLS_CERT_FILE= +TLS_KEY_FILE= + # Roots to scan for Git repositories, comma-separated (absolute paths). # Inside Docker these must be the *container* paths that the host roots are # mounted to (see docker-compose.yml). Example: /repos,/work/other diff --git a/.gitignore b/.gitignore index ac0f624..4e668bd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ gitmanager.exe # Logs *.log +# Local TLS certificates (generated with mkcert; never commit) +/certs/ + # Editor / OS .DS_Store .idea/ diff --git a/AGENT.md b/AGENT.md index 51bf3d9..7e73898 100644 --- a/AGENT.md +++ b/AGENT.md @@ -345,6 +345,13 @@ obeys the safety rules (§1.4). `github.com/modelcontextprotocol/go-sdk`. Claude Desktop connects to it as a **custom connector**. Like the rest of the app it is **localhost‑bound and unauthenticated** (Section 0) — do not expose it off‑host without asking. +- **The connector requires `https://`** (Claude Desktop rejects plain `http`). + So `/mcp` is also served over **TLS on localhost** with a **locally‑trusted + cert** (mkcert‑generated; the app just reads the cert/key files). Installing the + local CA into the OS trust store is a **host setup step the user performs**, not + something the app or Claude does (it is a security‑settings change). Do **not** + reach for a public tunnel to get HTTPS — that would expose an unauthenticated + repo‑management app to the internet. - MCP **tools are thin adapters** over the service layer — no Git/forge logic in the tool handlers (§1.7). Expected tools (grow as features land): - Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, @@ -507,9 +514,11 @@ silently guess.)* - ✅ **RESOLVED 2026-09-19:** **Project handoff is cooperative** — user requests a switch, Claude finishes to a safe checkpoint, switches, and the user is notified; pull‑first, not autonomous (Section 8.3). -- **MCP connector setup:** confirm this Claude Desktop build supports adding a - local Streamable‑HTTP custom connector (`http://127.0.0.1:8080/mcp`); if not, - fall back to a stdio shim. +- ✅ **RESOLVED 2026-09-20:** Claude Desktop's custom connector **requires + `https://`**. Resolved with **local TLS via mkcert** — connect to + `https://localhost:8443/mcp` (HTTP dashboard stays on `:8080`). The user runs + `mkcert -install` + cert generation on the host (§8.1); the stdio‑shim fallback + is no longer needed. - **Idle‑trigger for handoff:** the pull model syncs at Claude's turn boundaries. If Claude is idle when the user switches, decide the nudge (user's next message, a heartbeat/poll, or a host push if available) — do not assume instant wake. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1353d95..5dca79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,3 +69,18 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. - **Gotcha:** Docker-on-Windows bind mounts do NOT deliver filesystem events, so air's watch-based reload silently never fired. Fixed by enabling air polling (`poll = true`, `poll_interval = 500` in `.air.toml`). + +## 2026-09-20 — HTTPS for the MCP connector (local TLS via mkcert) +- **What:** Added an optional HTTPS listener alongside HTTP. New config + `HTTPS_ADDR`, `TLS_CERT_FILE`, `TLS_KEY_FILE`; when set, `cmd/server` starts + `e.StartTLS` on the same Echo app (best-effort — a missing cert logs a warning + and stays HTTP-only). docker-compose publishes `127.0.0.1:8443` and points the + TLS vars at `certs/localhost.pem` (mounted via the existing source mount). + `.gitignore` ignores `/certs/`; `.env.example` documents the mkcert steps. +- **Why:** Claude Desktop's custom MCP connector only accepts `https://` URLs. + Local TLS with an mkcert-trusted cert lets `https://localhost:8443/mcp` work + without exposing the unauthenticated app via a public tunnel (AGENT.md §8.1). +- **Affects:** `internal/config`, `cmd/server/main.go`, `docker-compose.yml`, + `.gitignore`, `.env.example`, `AGENT.md` (§8.1, §11). +- **Host setup (user-run):** the local CA install (`mkcert -install`) is a + security-settings change performed by the user, not the app. diff --git a/cmd/server/main.go b/cmd/server/main.go index a86ac66..6bc3b1d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -110,6 +110,22 @@ func main() { }() log.Info("listening", "addr", cfg.ListenAddr) + // Optional HTTPS listener (same Echo app). Required for the MCP connector, + // which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable + // cert logs a warning and leaves the app running over HTTP. + if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { + if _, err := os.Stat(cfg.TLSCertFile); err != nil { + log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err) + } else { + go func() { + if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { + log.Error("TLS server error", "err", err) + } + }() + log.Info("listening (https)", "addr", cfg.HTTPSAddr) + } + } + quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit diff --git a/docker-compose.yml b/docker-compose.yml index 4149767..6c49fed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,10 +12,17 @@ services: # Bind all interfaces INSIDE the container so the published port reaches # it; the `ports` mapping below still keeps it localhost-only on the HOST. - LISTEN_ADDR=0.0.0.0:8080 + # HTTPS for the MCP connector (Claude Desktop only accepts https URLs). + # Certs are generated on the host with mkcert (see README/.env.example) + # and mounted read-only below. + - HTTPS_ADDR=0.0.0.0:8443 + - TLS_CERT_FILE=/app/certs/localhost.pem + - TLS_KEY_FILE=/app/certs/localhost-key.pem # The scanner looks here; matches the volume mount below. - GIT_REPO_ROOTS=/repos ports: - "127.0.0.1:8080:8080" + - "127.0.0.1:8443:8443" volumes: # Source, for hot reload. - .:/app diff --git a/internal/config/config.go b/internal/config/config.go index 738318c..5e9e445 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,14 @@ import ( // Config is the typed application configuration. type Config struct { - ListenAddr string // address the HTTP server binds to + ListenAddr string // address the plain HTTP server binds to + + // TLS: when HTTPSAddr and both cert/key files are set, an HTTPS listener is + // started in addition to the HTTP one. Claude Desktop's MCP connector only + // accepts https:// URLs, so /mcp must be reachable over TLS (AGENT.md §8.1). + HTTPSAddr string // address the HTTPS server binds to ("" disables TLS) + TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store) + TLSKeyFile string // PEM private key RepoRoots []string // roots to scan for git repositories GitBin string // path to the git binary @@ -38,6 +45,9 @@ func Load() (Config, error) { c := Config{ ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"), + HTTPSAddr: env("HTTPS_ADDR", ""), + TLSCertFile: env("TLS_CERT_FILE", ""), + TLSKeyFile: env("TLS_KEY_FILE", ""), RepoRoots: splitList(env("GIT_REPO_ROOTS", "")), GitBin: env("GIT_BIN", "git"), ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),