From 928f23ab1b7cb46e5b33535c80244fa07abd9a19 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 07:31:29 +0200 Subject: [PATCH] feat(mcp): optional bearer-token auth via SUPERVISOR_MCP_TOKEN Enables exposing the supervisor MCP via Tailscale Funnel for claude.ai custom-connector tests. Auth is opt-in: empty SUPERVISOR_MCP_TOKEN preserves the existing unauthenticated behavior for tailnet-internal callers and local dev. When the token is set, every request must carry "Authorization: Bearer " or it is rejected with HTTP 401 and a JSON-RPC -32001 error. Comparison uses crypto/subtle.ConstantTimeCompare; the token value and the supplied header are never logged. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + cmd/supervisor/main.go | 2 +- internal/config/config.go | 2 ++ internal/config/config_test.go | 4 +++ internal/mcp/server.go | 40 +++++++++++++++++++++++-- internal/mcp/server_test.go | 53 ++++++++++++++++++++++++++++++---- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0d17a7e..3577d84 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ The supervisor probes connectivity at call time: | `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs | | `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion | | `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing | +| `SUPERVISOR_MCP_TOKEN` | — | Optional bearer token for the supervisor MCP HTTP endpoint; when empty, no auth is enforced | ## Phase 2 (planned) diff --git a/cmd/supervisor/main.go b/cmd/supervisor/main.go index 17e75e5..30a08bb 100644 --- a/cmd/supervisor/main.go +++ b/cmd/supervisor/main.go @@ -150,7 +150,7 @@ func main() { BrainDir: cfg.BrainDir, })) - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, cfg.MCPAuthToken) mux := http.NewServeMux() mux.Handle("/mcp", srv) diff --git a/internal/config/config.go b/internal/config/config.go index 0865160..1da69de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain + MCPAuthToken string // SUPERVISOR_MCP_TOKEN — optional bearer token for MCP HTTP; empty disables auth } func Load() (Config, error) { @@ -28,6 +29,7 @@ func Load() (Config, error) { cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "") cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions") cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain") + cfg.MCPAuthToken = os.Getenv("SUPERVISOR_MCP_TOKEN") return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f3c7ec9..e1a2cf6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -16,6 +16,7 @@ func TestLoadDefaults(t *testing.T) { t.Setenv("INGEST_BASE_URL", "") t.Setenv("SUPERVISOR_SESSIONS_DIR", "") t.Setenv("SUPERVISOR_BRAIN_DIR", "") + t.Setenv("SUPERVISOR_MCP_TOKEN", "") cfg, err := config.Load() require.NoError(t, err) @@ -25,6 +26,7 @@ func TestLoadDefaults(t *testing.T) { assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL) assert.Equal(t, "./brain/sessions", cfg.SessionsDir) assert.Equal(t, "./brain", cfg.BrainDir) + assert.Equal(t, "", cfg.MCPAuthToken) } func TestLoadFromEnv(t *testing.T) { @@ -32,6 +34,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("LITELLM_BASE_URL", "http://localhost:4000") t.Setenv("LITELLM_API_KEY", "test-key") t.Setenv("SUPERVISOR_CONFIG_DIR", "/etc/supervisor") + t.Setenv("SUPERVISOR_MCP_TOKEN", "secret-token") cfg, err := config.Load() require.NoError(t, err) @@ -39,4 +42,5 @@ func TestLoadFromEnv(t *testing.T) { assert.Equal(t, "http://localhost:4000", cfg.LiteLLMBaseURL) assert.Equal(t, "test-key", cfg.LiteLLMAPIKey) assert.Equal(t, "/etc/supervisor", cfg.ConfigDir) + assert.Equal(t, "secret-token", cfg.MCPAuthToken) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 271a2dd..a487730 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -2,8 +2,11 @@ package mcp import ( "context" + "crypto/subtle" "encoding/json" + "log/slog" "net/http" + "strings" "github.com/mathiasbq/supervisor/internal/registry" ) @@ -29,14 +32,22 @@ type rpcError struct { // Server is an HTTP handler implementing the MCP JSON-RPC protocol. type Server struct { - reg *registry.Registry + reg *registry.Registry + token string } -func NewServer(reg *registry.Registry) *Server { - return &Server{reg: reg} +// NewServer constructs an MCP HTTP handler. If token is non-empty, every +// request must carry "Authorization: Bearer " or it is rejected with +// HTTP 401 and JSON-RPC error -32001. Empty token disables auth (default). +func NewServer(reg *registry.Registry, token string) *Server { + return &Server{reg: reg, token: token} } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(w, r) { + return + } + var req request if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, nil, -32700, "parse error") @@ -93,6 +104,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) } +// checkAuth verifies the bearer token when one is configured. Returns true if +// the request may proceed, false if it has been rejected (401 already written). +func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool { + if s.token == "" { + return true + } + + const prefix = "Bearer " + hdr := r.Header.Get("Authorization") + if !strings.HasPrefix(hdr, prefix) || + subtle.ConstantTimeCompare([]byte(hdr[len(prefix):]), []byte(s.token)) != 1 { + slog.Warn("mcp auth rejected", "remote", r.RemoteAddr, "method", r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(response{ + JSONRPC: "2.0", + Error: &rpcError{Code: -32001, Message: "unauthorized"}, + }) + return false + } + return true +} + func writeError(w http.ResponseWriter, id any, code int, msg string) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(response{ diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 166893f..a9fe15c 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -23,7 +23,7 @@ func jsonBody(t *testing.T, v any) *bytes.Buffer { func TestMCPInitialize(t *testing.T) { reg := registry.New() - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, "") req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ "jsonrpc": "2.0", @@ -45,7 +45,7 @@ func TestMCPInitialize(t *testing.T) { func TestMCPToolsList(t *testing.T) { reg := registry.New() - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, "") req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": map[string]any{}, @@ -63,7 +63,7 @@ func TestMCPToolsList(t *testing.T) { func TestMCPUnknownMethod(t *testing.T) { reg := registry.New() - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, "") req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ "jsonrpc": "2.0", "id": 3, "method": "unknown/method", "params": map[string]any{}, @@ -80,7 +80,7 @@ func TestMCPUnknownMethod(t *testing.T) { func TestMCPNotificationKnownMethodGetsNoResponseBody(t *testing.T) { reg := registry.New() - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, "") // JSON-RPC 2.0 notification: "id" field absent. Per spec, server MUST NOT // reply. notifications/initialized is part of the standard MCP handshake. @@ -97,9 +97,52 @@ func TestMCPNotificationKnownMethodGetsNoResponseBody(t *testing.T) { "notifications must not receive a response body") } +func TestMCPAuth(t *testing.T) { + const token = "s3cr3t" + + cases := []struct { + name string + token string + authHeader string + wantStatus int + }{ + {"no token configured passes without header", "", "", http.StatusOK}, + {"correct bearer passes", token, "Bearer " + token, http.StatusOK}, + {"wrong bearer rejected", token, "Bearer wrong", http.StatusUnauthorized}, + {"missing header rejected", token, "", http.StatusUnauthorized}, + {"wrong scheme rejected", token, "Basic " + token, http.StatusUnauthorized}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + reg := registry.New() + srv := mcp.NewServer(reg, tc.token) + + req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{}, + })) + req.Header.Set("Content-Type", "application/json") + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, tc.wantStatus, rr.Code) + if tc.wantStatus == http.StatusUnauthorized { + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + rpcErr, ok := resp["error"].(map[string]any) + require.True(t, ok, "expected error object in response") + assert.Equal(t, float64(-32001), rpcErr["code"]) + } + }) + } +} + func TestMCPNotificationUnknownMethodGetsNoResponseBody(t *testing.T) { reg := registry.New() - srv := mcp.NewServer(reg) + srv := mcp.NewServer(reg, "") req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ "jsonrpc": "2.0",