diff --git a/.env.example b/.env.example index 13e51c4..99e02d4 100644 --- a/.env.example +++ b/.env.example @@ -151,6 +151,15 @@ INGEST_INTERVAL=60s # WEBUI_MODE=interactive # AUTH_TOKEN=change-me-long-random +# Serve the MCP tool surface over streamable HTTP at /mcp (in addition to the +# default stdio transport), so an MCP client can register an HTTP transport that +# AUTO-RECONNECTS across app restarts (e.g. `brainiac update`) — no manual +# reconnect. Requires AUTH_TOKEN (the endpoint exposes write tools); Layer-1 only +# (not compatible with configured principals in v1). Register with: +# claude mcp add --transport http brainiac http://localhost:8088/mcp \ +# --header "Authorization: Bearer $AUTH_TOKEN" +# MCP_HTTP=1 + # App-level chunk-text encryption at rest (optional, #377). OFF by default (the # recommended posture is full-disk/volume encryption, #371; this is defense-in- # depth for a shared/managed Postgres). Base64 of a 32-byte AES-256 key: diff --git a/SYSTEM.md b/SYSTEM.md index 3a693ba..559fe13 100644 --- a/SYSTEM.md +++ b/SYSTEM.md @@ -383,6 +383,21 @@ as the adoption signal. Newest first. +- **2026-07-21** — **Opt-in HTTP (streamable) MCP transport (#440).** The MCP server was stdio-only + (`cmd/mcp` → `StdioTransport`), and the documented deploy runs it as `docker compose exec app + /brainiac-mcp` — a subprocess **inside** the app container. Every `docker compose up -d` / `brainiac + update` recreates that container, killing the subprocess in every client; MCP clients do **not** auto- + restart stdio servers (they *do* auto-reconnect HTTP/SSE ones), so each session had to manually + reconnect. Fix: serve the same tool surface over **streamable HTTP** at `POST/GET /mcp` on the app's + chi router (`mcp.NewStreamableHTTPHandler` wrapping `mcpserver.New`), so clients register an HTTP + transport that reconnects on its own. Opt-in via `HTTP.mcp` / `MCP_HTTP` (off by default; stdio stays + the default transport), gated by the existing `bearerAuth(AUTH_TOKEN)` (it exposes write tools; the app + binds localhost). Fail-closed config: `MCP_HTTP` requires `AUTH_TOKEN` and is **Layer-1 only** — rejected + when hard-isolation principals are configured, since a single-principal HTTP surface would bypass the + per-request isolation the `/api` routes enforce (#120/#185). v1 omits the `ingest` tool over HTTP + (`nil` importFn; stdio/CLI keep it). Follow-ups: per-connection principal from the bearer token, and + ingest parity. Register: `claude mcp add --transport http brainiac http://localhost:8088/mcp --header + "Authorization: Bearer $AUTH_TOKEN"`. - **2026-07-20** — **Cross-document entity resolution for batch extraction (#431, ingestion P2).** Per-chunk resolution (`resolveOrProposeNode`) matches only **exact** canonical names, so the same entity written under variant names across a batch's documents ("OrderService" in one doc, "Order Service" in another) diff --git a/cmd/http/main.go b/cmd/http/main.go index 152f45b..add37f7 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -22,12 +22,14 @@ import ( "syscall" "time" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/programmism/brainiac/internal/applog" "github.com/programmism/brainiac/internal/chunk" "github.com/programmism/brainiac/internal/config" "github.com/programmism/brainiac/internal/connectors" "github.com/programmism/brainiac/internal/core" "github.com/programmism/brainiac/internal/logbuf" + "github.com/programmism/brainiac/internal/mcpserver" "github.com/programmism/brainiac/internal/plugins/anthropic" "github.com/programmism/brainiac/internal/plugins/density" "github.com/programmism/brainiac/internal/plugins/markdown" @@ -118,6 +120,16 @@ func run() error { if cfg.Embedding.MaxConcurrency > 0 { log.Printf("embed concurrency capped at %d in-flight round-trips to Ollama (#270)", cfg.Embedding.MaxConcurrency) } + // MCP over streamable HTTP (#440), opt-in via MCP_HTTP. Config validation + // guarantees AuthToken is set and no principals are configured here, so this is + // a single Layer-1 surface gated by the bearer token in server.New. nil importFn + // omits only the ingest tool over HTTP (stdio MCP + CLI keep it, v1 scope). + var mcpHandler http.Handler + if cfg.HTTP.MCP { + mcpSrv := mcpserver.New(c, nil, nil) + mcpHandler = mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpSrv }, nil) + log.Printf("MCP over HTTP ON: POST/GET /mcp behind AUTH_TOKEN — clients can register an HTTP transport that auto-reconnects (#440)") + } handler := server.New(pool, embedderCheck, c, server.Options{ Writable: writable, AuthToken: cfg.HTTP.AuthToken, @@ -125,6 +137,7 @@ func run() error { Logs: logs, RateLimitRPS: cfg.HTTP.RateLimitRPS, RateLimitBurst: cfg.EffectiveRateLimitBurst(), + MCP: mcpHandler, }) srv := &http.Server{ Addr: cfg.HTTP.Addr, diff --git a/docker-compose.yml b/docker-compose.yml index 29651f5..dcd1851 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,9 @@ services: # WebUI write actions (merge/retire/approve) are off until this is # "interactive" AND AUTH_TOKEN is set (secure by default). WEBUI_MODE: ${WEBUI_MODE:-} + # Serve MCP over streamable HTTP at /mcp (behind AUTH_TOKEN) so clients can + # register an HTTP transport that auto-reconnects across restarts (#440). + MCP_HTTP: ${MCP_HTTP:-} NOTION_TOKEN: ${NOTION_TOKEN:-} INGEST_INTERVAL: ${INGEST_INTERVAL:-} # Optional local-LLM extractor (off unless EXTRACTOR=local-llm). URL defaults diff --git a/internal/config/config.go b/internal/config/config.go index a8c46e7..15de833 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -337,6 +337,12 @@ type HTTPConfig struct { // allowed above the sustained rate. Defaults to ceil(RateLimitRPS) (min 1) when // rate limiting is on and this is unset. RateLimitBurst int `yaml:"rate_limit_burst,omitempty"` + // MCP, when true, also serves the MCP tool surface over streamable HTTP at + // POST/GET /mcp (behind AUTH_TOKEN), so clients can register it as an HTTP + // transport and auto-reconnect across app restarts. Off by default; stdio + // (cmd/mcp) stays the default transport. Env override: MCP_HTTP. Layer-1 only + // in v1 — rejected when hard-isolation principals are configured (#440). + MCP bool `yaml:"mcp,omitempty"` } // StorageConfig points at Postgres. DSN is a secret — set it via DATABASE_URL. @@ -620,6 +626,9 @@ func (c *Config) applyEnvOverrides() { if v := os.Getenv("AUTH_TOKEN"); v != "" { c.HTTP.AuthToken = v } + if v := os.Getenv("MCP_HTTP"); v != "" { + c.HTTP.MCP = v == "true" || v == "1" + } if v := os.Getenv("HTTP_RATE_LIMIT_RPS"); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { c.HTTP.RateLimitRPS = f @@ -992,6 +1001,18 @@ func (c *Config) Validate() error { if c.OCR.Enabled && c.OCR.Command == "" { return errors.New("ocr.command must be set when ocr.enabled is true (e.g. \"tesseract\")") } + // MCP-over-HTTP (#440): fail closed. It exposes write tools, so it requires a + // bearer token; and v1 serves a single Layer-1 surface, so it must not be + // enabled alongside hard-isolation principals (that would bypass per-request + // isolation — a per-connection-principal HTTP path is a follow-up). + if c.HTTP.MCP { + if c.HTTP.AuthToken == "" { + return errors.New("http.mcp (MCP_HTTP) requires AUTH_TOKEN — the HTTP MCP endpoint exposes write tools") + } + if c.PrincipalsEnabled() { + return errors.New("http.mcp (MCP_HTTP) is Layer-1 only in v1 and cannot be combined with configured principals (#440)") + } + } // Retention (#363): if set, max_age must be a positive Go duration. if c.Retention.MaxAge != "" { d, err := time.ParseDuration(c.Retention.MaxAge) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b58b794..f59857e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -881,3 +881,35 @@ func TestNotionTokenAutoCreatesSource(t *testing.T) { t.Fatalf("NOTION_TOKEN should auto-create a notion source with the token, got %+v", s) } } + +// TestMCPHTTPValidation covers the fail-closed guards for the opt-in HTTP MCP +// endpoint (#440): it needs a bearer token, and v1 is Layer-1 only. +func TestMCPHTTPValidation(t *testing.T) { + base := Default() + base.Storage.DSN = "postgres://x" + + // Exposes write tools → requires AUTH_TOKEN. + c := *base + c.HTTP.MCP = true + if err := c.Validate(); err == nil { + t.Fatal("http.mcp without AUTH_TOKEN should fail validation") + } + c.HTTP.AuthToken = "tok" + if err := c.Validate(); err != nil { + t.Fatalf("http.mcp with a token should validate: %v", err) + } + + // Layer-1 only in v1: rejected alongside configured principals. + c.Principals = []PrincipalConfig{{Name: "a", Read: []string{"a"}, Write: "a", Token: "a-token-0000000000000000000000000000"}} + if err := c.Validate(); err == nil { + t.Fatal("http.mcp with principals configured should fail validation (Layer-1 only)") + } + + // Env override MCP_HTTP → HTTP.MCP. + t.Setenv("MCP_HTTP", "1") + d := Default() + d.applyEnvOverrides() + if !d.HTTP.MCP { + t.Fatal("MCP_HTTP=1 should set HTTP.MCP") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5faa73f..29a5549 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -70,6 +70,11 @@ type Options struct { // ceil(rps), min 1, when <= 0). 0 RPS = no limiting. RateLimitRPS float64 RateLimitBurst int + // MCP, when non-nil, is the streamable-HTTP MCP handler mounted at /mcp behind + // the AuthToken bearer gate, so clients can register brainiac as an HTTP MCP + // transport and auto-reconnect across app restarts (#440). Nil = not served + // (stdio remains the default transport). + MCP http.Handler } // New builds the HTTP handler: @@ -355,6 +360,13 @@ func New(db Pinger, embedder Checker, c *core.Core, opts Options) http.Handler { }) } + // MCP over streamable HTTP (#440), behind the same bearer gate as writes: the + // endpoint exposes write tools and the app binds localhost by default, so it + // still requires AUTH_TOKEN. Mounted only when the app wired a handler in. + if opts.MCP != nil { + r.Handle("/mcp", bearerAuth(opts.AuthToken)(opts.MCP)) + } + // Read-only WebUI as a catch-all (specific routes above win). r.Handle("/*", webui.Handler()) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6f9025a..656dfad 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -12,9 +12,11 @@ import ( "time" "github.com/go-chi/chi/v5/middleware" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/programmism/brainiac/internal/core" "github.com/programmism/brainiac/internal/logbuf" + "github.com/programmism/brainiac/internal/mcpserver" ) type fakePinger struct{ err error } @@ -356,3 +358,63 @@ func TestReadyzDBDownIs503(t *testing.T) { t.Fatalf("db = %q", body["db"]) } } + +// TestMCPHTTPEndpointBearerGate verifies the opt-in /mcp streamable endpoint is +// mounted behind the AuthToken bearer gate (#440): unauthenticated calls are 401, +// and a valid token gets past auth into the MCP handler (which answers initialize). +func TestMCPHTTPEndpointBearerGate(t *testing.T) { + c := core.New(nil, nil, nil) // initialize handshake needs no DB + mcpSrv := mcpserver.New(c, nil, nil) + mcpH := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpSrv }, nil) + h := New(fakePinger{}, nil, c, Options{AuthToken: "test-token", MCP: mcpH}) + + const initReq = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}` + newReq := func(token string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(initReq)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Accept", "application/json, text/event-stream") + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + return r + } + + // No token → 401, and the MCP handler must not run. + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newReq("")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated /mcp: got %d, want 401", rec.Code) + } + + // Wrong token → 401. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, newReq("nope")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad-token /mcp: got %d, want 401", rec.Code) + } + + // Valid token → past the gate; the MCP handler answers initialize (200 with + // the server info), not a 401. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, newReq("test-token")) + if rec.Code == http.StatusUnauthorized { + t.Fatalf("authed /mcp: got 401, want past the bearer gate") + } + if rec.Code != http.StatusOK { + t.Fatalf("authed /mcp initialize: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "serverInfo") { + t.Fatalf("initialize response missing serverInfo: %s", rec.Body.String()) + } +} + +// TestMCPHTTPEndpointNotMountedByDefault: with no MCP handler in Options, /mcp is +// not a route — it falls through to the WebUI catch-all (not a 401/200 MCP reply). +func TestMCPHTTPEndpointNotMountedByDefault(t *testing.T) { + h := New(fakePinger{}, nil, core.New(nil, nil, nil), Options{AuthToken: "test-token"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader("{}"))) + if rec.Code == http.StatusUnauthorized { + t.Fatalf("/mcp should not be gated (not mounted) when Options.MCP is nil, got 401") + } +}