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 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 07:40:21 -04:00
parent 2d7f814a23
commit f23f2f2b30
7 changed files with 78 additions and 4 deletions
+14
View File
@@ -7,6 +7,20 @@
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately. # authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
LISTEN_ADDR=127.0.0.1:8080 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). # Roots to scan for Git repositories, comma-separated (absolute paths).
# Inside Docker these must be the *container* paths that the host roots are # Inside Docker these must be the *container* paths that the host roots are
# mounted to (see docker-compose.yml). Example: /repos,/work/other # mounted to (see docker-compose.yml). Example: /repos,/work/other
+3
View File
@@ -10,6 +10,9 @@ gitmanager.exe
# Logs # Logs
*.log *.log
# Local TLS certificates (generated with mkcert; never commit)
/certs/
# Editor / OS # Editor / OS
.DS_Store .DS_Store
.idea/ .idea/
+12 -3
View File
@@ -345,6 +345,13 @@ obeys the safety rules (§1.4).
`github.com/modelcontextprotocol/go-sdk`. Claude Desktop connects to it as a `github.com/modelcontextprotocol/go-sdk`. Claude Desktop connects to it as a
**custom connector**. Like the rest of the app it is **localhostbound and **custom connector**. Like the rest of the app it is **localhostbound and
unauthenticated** (Section 0) — do not expose it offhost without asking. unauthenticated** (Section 0) — do not expose it offhost without asking.
- **The connector requires `https://`** (Claude Desktop rejects plain `http`).
So `/mcp` is also served over **TLS on localhost** with a **locallytrusted
cert** (mkcertgenerated; 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 securitysettings change). Do **not**
reach for a public tunnel to get HTTPS — that would expose an unauthenticated
repomanagement app to the internet.
- MCP **tools are thin adapters** over the service layer — no Git/forge logic in - 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): the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, - 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 - ✅ **RESOLVED 2026-09-19:** **Project handoff is cooperative** — user requests a
switch, Claude finishes to a safe checkpoint, switches, and the user is notified; switch, Claude finishes to a safe checkpoint, switches, and the user is notified;
pullfirst, not autonomous (Section 8.3). pullfirst, not autonomous (Section 8.3).
- **MCP connector setup:** confirm this Claude Desktop build supports adding a - ✅ **RESOLVED 2026-09-20:** Claude Desktop's custom connector **requires
local StreamableHTTP custom connector (`http://127.0.0.1:8080/mcp`); if not, `https://`**. Resolved with **local TLS via mkcert** — connect to
fall back to a stdio shim. `https://localhost:8443/mcp` (HTTP dashboard stays on `:8080`). The user runs
`mkcert -install` + cert generation on the host (§8.1); the stdioshim fallback
is no longer needed.
- **Idletrigger for handoff:** the pull model syncs at Claude's turn boundaries. - **Idletrigger 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, 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. a heartbeat/poll, or a host push if available) — do not assume instant wake.
+15
View File
@@ -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 - **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 air's watch-based reload silently never fired. Fixed by enabling air polling
(`poll = true`, `poll_interval = 500` in `.air.toml`). (`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.
+16
View File
@@ -110,6 +110,22 @@ func main() {
}() }()
log.Info("listening", "addr", cfg.ListenAddr) 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM) signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit <-quit
+7
View File
@@ -12,10 +12,17 @@ services:
# Bind all interfaces INSIDE the container so the published port reaches # Bind all interfaces INSIDE the container so the published port reaches
# it; the `ports` mapping below still keeps it localhost-only on the HOST. # it; the `ports` mapping below still keeps it localhost-only on the HOST.
- LISTEN_ADDR=0.0.0.0:8080 - 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. # The scanner looks here; matches the volume mount below.
- GIT_REPO_ROOTS=/repos - GIT_REPO_ROOTS=/repos
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
- "127.0.0.1:8443:8443"
volumes: volumes:
# Source, for hot reload. # Source, for hot reload.
- .:/app - .:/app
+11 -1
View File
@@ -14,7 +14,14 @@ import (
// Config is the typed application configuration. // Config is the typed application configuration.
type Config struct { 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 RepoRoots []string // roots to scan for git repositories
GitBin string // path to the git binary GitBin string // path to the git binary
@@ -38,6 +45,9 @@ func Load() (Config, error) {
c := Config{ c := Config{
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"), 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", "")), RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
GitBin: env("GIT_BIN", "git"), GitBin: env("GIT_BIN", "git"),
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4), ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),