Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions cmd/http/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -118,13 +120,24 @@ 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,
Auth: auth,
Logs: logs,
RateLimitRPS: cfg.HTTP.RateLimitRPS,
RateLimitBurst: cfg.EffectiveRateLimitBurst(),
MCP: mcpHandler,
})
srv := &http.Server{
Addr: cfg.HTTP.Addr,
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
12 changes: 12 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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())

Expand Down
62 changes: 62 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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")
}
}
Loading