brain_edit_root was gated but unobserved: on success it emitted a slog.Warn and nothing else. That proves a rewrite was allowed, not that one happened — and brain-sync commits it within 15 minutes authored `brain-sync`, so git history cannot tell a CLAUDE.md rewrite apart from routine note traffic. CLAUDE.md is what the next agent loads as instructions. A successful brain_edit_root now emits an ntfy notification and an AuditEntry through the same sink a capture already uses. notes.Result gains PrevContentHash so both ends of the change are identifiable. Path and hashes only in the notification body. It leaves the cluster for a phone via a third-party topic, and a hash identifies the rewrite while the bytes would leak whatever was written into an instruction file. Reserved at Internal, not Confidential: Confidential makes DegradingSink refuse to record while loki is down rather than buffer, which would drop exactly the record that matters most. Observation is best-effort and never fails the call. The bytes are already on disk and will be committed, so returning an error would report a non-rewrite to the caller while the rewrite stands. A failure to observe logs at ERROR — that is the case where the trail itself broke. Ordinary brain_edit on knowledge//wiki/ is deliberately NOT audited. Git history already carries the same before/after bytes with a diff; the asymmetry is that governance files change what later agents obey. Routine note traffic in the same trail would also bury the rare governance record, which is how an alert stops being read. Red-first: the audit-contents and notification tests failed on an empty sink before the observer existed. The three negative tests (refused call, failed edit, ordinary note edit) passed from the start, pinning behaviour that must not change. Refs #97, #96, brain#4.
311 lines
10 KiB
Go
311 lines
10 KiB
Go
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
|
// brain_pending, brain_promote, brain_tunnel, brain_ingest,
|
|
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
|
// brain_context, session_log, and capture (the #55 relay tool, registered
|
|
// only when WithCapture is set).
|
|
//
|
|
// brain_index dispatches but is deliberately absent from tools/list: it is
|
|
// auto-called after every write, so surfacing it as a caller verb is
|
|
// cognitive load with no decision behind it (ADR-0012 rule 4, #78). It stays
|
|
// callable for maintenance — de-surfacing hides the verb, not the operation.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
|
)
|
|
|
|
type request struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID any `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type response struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID any `json:"id,omitempty"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
|
type Server struct {
|
|
brainDir string
|
|
govAudit capture.AuditSink
|
|
govNotify govNotifier
|
|
pipeline pipeline.Config
|
|
llm pipeline.CompleteFunc
|
|
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
|
reranker *reranker.Client // nil = no rerank, BM25 top-10 → LLM
|
|
vector search.VectorSearcher // nil = BM25-only retrieval
|
|
embedder search.Embedder // nil = BM25-only retrieval
|
|
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
|
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
|
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
|
capture *captureDeps // nil = capture MCP tool disabled (#55 relay)
|
|
}
|
|
|
|
// captureDeps holds what the MCP `capture` tool (the #55 relay door for
|
|
// MCP-native harnesses like claude.ai) needs: the use-case, the auth bits
|
|
// to re-derive the caller's principal from the Bearer header (the chassis
|
|
// middleware gates but discards the principal), and the origin resolver.
|
|
type captureDeps struct {
|
|
svc *capture.Service
|
|
validator capturehttp.Validator
|
|
staticToken string
|
|
staticPrincipal string
|
|
resolver capturehttp.OriginResolver
|
|
}
|
|
|
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
|
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
|
// answerLLM drives brain_answer and brain_classify; nil disables those tools.
|
|
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc, answerLLM pipeline.CompleteFunc) *Server {
|
|
cfg := pipeline.Config{}
|
|
if pipelineCfg != nil {
|
|
cfg = *pipelineCfg
|
|
}
|
|
return &Server{
|
|
brainDir: brainDir,
|
|
pipeline: cfg,
|
|
llm: llm,
|
|
answerLLM: answerLLM,
|
|
store: brainstore.New(brainDir),
|
|
}
|
|
}
|
|
|
|
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
|
// brain_answer retrieves a wider BM25 candidate set and prunes it to
|
|
// the relevant ones before LLM synthesis. Returns the server for
|
|
// fluent chaining.
|
|
func (s *Server) WithReranker(r *reranker.Client) *Server {
|
|
s.reranker = r
|
|
return s
|
|
}
|
|
|
|
// WithHybridRetrieval wires the embedding store and embedder so
|
|
// brain_query and brain_answer run BM25 + pgvector merged via RRF
|
|
// instead of BM25 alone. Either nil disables hybrid mode.
|
|
func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder) *Server {
|
|
s.vector = v
|
|
s.embedder = e
|
|
return s
|
|
}
|
|
|
|
// WithGraph wires the brain entities + edges store so every successful
|
|
// brain_write / brain_ingest / brain_tunnel re-indexes its written docs
|
|
// into the graph, and so brain_graph + GraphRAG-augmented brain_answer
|
|
// are available. nil disables graph features and is the legacy default.
|
|
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
|
if g == nil {
|
|
s.graph = nil
|
|
s.store.WithGraph(nil)
|
|
return s
|
|
}
|
|
s.graph = g
|
|
s.store.WithGraph(g)
|
|
return s
|
|
}
|
|
|
|
// WithIssueTracker injects the Gitea ticket tracker behind the
|
|
// capture.IssueTracker interface. nil leaves ticket integration off. The
|
|
// use-case (capture) consumes this in #53; it is wired here so the
|
|
// dependency is constructed once and stays swappable/testable.
|
|
func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server {
|
|
s.tracker = t
|
|
return s
|
|
}
|
|
|
|
// IssueTracker returns the injected ticket tracker (nil when unconfigured).
|
|
func (s *Server) IssueTracker() capture.IssueTracker {
|
|
return s.tracker
|
|
}
|
|
|
|
// BrainStore returns the shared brain store (graph-wired once WithGraph
|
|
// has run), so the capture use-case writes through the exact same
|
|
// implementation as the MCP handlers.
|
|
func (s *Server) BrainStore() *brainstore.Store {
|
|
return s.store
|
|
}
|
|
|
|
// WithCapture enables the MCP `capture` tool (#55) — the relay door for
|
|
// MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) that cannot run
|
|
// the use-case in-process. It forwards to the same CaptureService as
|
|
// POST /capture, deriving the caller's principal + origin from the same
|
|
// auth credentials that gate /mcp. nil svc leaves the tool unregistered.
|
|
func (s *Server) WithCapture(svc *capture.Service, validator capturehttp.Validator, staticToken, staticPrincipal string, resolver capturehttp.OriginResolver) *Server {
|
|
if svc == nil {
|
|
s.capture = nil
|
|
return s
|
|
}
|
|
if staticPrincipal == "" {
|
|
staticPrincipal = "local-cli"
|
|
}
|
|
s.capture = &captureDeps{
|
|
svc: svc,
|
|
validator: validator,
|
|
staticToken: staticToken,
|
|
staticPrincipal: staticPrincipal,
|
|
resolver: resolver,
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
|
if r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
<-r.Context().Done()
|
|
return
|
|
}
|
|
|
|
var req request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, nil, -32700, "parse error")
|
|
return
|
|
}
|
|
|
|
// JSON-RPC 2.0 notifications (no id) must not receive a response.
|
|
if req.ID == nil {
|
|
return
|
|
}
|
|
|
|
var result any
|
|
var rpcErr *rpcError
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
result = map[string]any{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": "ingestion-brain", "version": "0.1.0"},
|
|
}
|
|
|
|
case "tools/list":
|
|
result = map[string]any{"tools": s.tools()}
|
|
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
|
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
|
break
|
|
}
|
|
// Re-derive the authenticated principal from the Bearer header so
|
|
// the capture tool can compute the trust-zone origin. The request
|
|
// is already gated by BearerMiddleware; this only recovers the
|
|
// identity that middleware discards.
|
|
ctx := r.Context()
|
|
if s.capture != nil {
|
|
if principal, viaStatic, ok := capturehttp.Authenticate(
|
|
r, s.capture.staticToken, s.capture.staticPrincipal, s.capture.validator); ok {
|
|
ctx = withPrincipal(ctx, principal, viaStatic)
|
|
}
|
|
}
|
|
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
|
if err != nil {
|
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
|
break
|
|
}
|
|
result = map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": string(out)}},
|
|
}
|
|
|
|
default:
|
|
rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response{
|
|
JSONRPC: "2.0",
|
|
ID: req.ID,
|
|
Result: result,
|
|
Error: rpcErr,
|
|
})
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, id any, code int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response{
|
|
JSONRPC: "2.0",
|
|
ID: id,
|
|
Error: &rpcError{Code: code, Message: msg},
|
|
})
|
|
}
|
|
|
|
// handleCall dispatches a tools/call to the appropriate tool handler.
|
|
func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
|
|
switch name {
|
|
case "brain_query":
|
|
return s.brainQuery(ctx, args)
|
|
case "brain_write":
|
|
return s.brainWrite(ctx, args)
|
|
case "brain_update":
|
|
return s.brainUpdate(ctx, args)
|
|
case "brain_get":
|
|
return s.brainGet(ctx, args)
|
|
case "brain_pending":
|
|
return s.brainPending(ctx, args)
|
|
case "brain_promote":
|
|
return s.brainPromote(ctx, args)
|
|
case "capture":
|
|
return s.brainCapture(ctx, args)
|
|
case "brain_read":
|
|
return s.brainRead(ctx, args)
|
|
case "brain_edit":
|
|
return s.brainEdit(ctx, args)
|
|
case "brain_edit_root":
|
|
// Dispatches but is intentionally absent from tools/list — see
|
|
// tools_notes.go for why a governance-file editor must not be
|
|
// discoverable by a model.
|
|
return s.brainEditRoot(ctx, args)
|
|
case "brain_index":
|
|
return s.brainIndex(ctx, args)
|
|
case "brain_tunnel":
|
|
return s.brainTunnel(ctx, args)
|
|
case "brain_ingest_raw":
|
|
return s.brainIngestRaw(ctx, args)
|
|
case "brain_ingest":
|
|
return s.brainIngest(ctx, args)
|
|
case "session_log":
|
|
return s.sessionLog(ctx, args)
|
|
case "brain_answer":
|
|
return s.brainAnswer(ctx, args)
|
|
case "brain_classify":
|
|
return s.brainClassify(ctx, args)
|
|
case "brain_graph":
|
|
return s.brainGraph(ctx, args)
|
|
case "brain_context":
|
|
return s.brainContext(ctx, args)
|
|
default:
|
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
|
}
|
|
}
|