feat(mcp): capture relay tool — MCP door for non-library harnesses (#55)
Adds the `capture` MCP tool: the #55 relay for harnesses that cannot run the use-case in-process (claude.ai Chat/Cowork/Design, Crush, Pi, LLM Council). They reach it through the existing /mcp OAuth connector. - Thin: forwards to the SAME CaptureService as POST /capture; holds no state and retains nothing beyond the I5 audit record. The containment properties accepted in infra security-baseline (I2 ledger) hold by construction. - Per-principal: ServeHTTP re-derives the caller's principal from the Bearer header (the chassis middleware gates but discards it) and stashes it in context; the tool resolves the trust-zone origin from it. A caller-asserted harness/origin in the body is ignored — origin is server-derived, so the I1 confidential refusal still fires for us-nexus callers (claude.ai), and sovereign-allowlisted JWT principals pass. - Registered only when WithCapture is wired (all three sites: tools(), handleCall, package doc); main wires REST + MCP from the same service, resolver, and credentials. Tests: listed-only-when-wired, forwards-via-static-principal, confidential-via-us-nexus-refused, confidential-via-sovereign-allowed, unauthenticated-rejected, caller-cannot-forge-origin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
||||
// brain_answer, brain_classify, brain_graph, brain_context, session_log.
|
||||
// brain_answer, brain_classify, brain_graph, brain_context, session_log,
|
||||
// and capture (the #55 relay tool, registered only when WithCapture is set).
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -50,6 +52,19 @@ type Server struct {
|
||||
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
|
||||
@@ -123,6 +138,29 @@ 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 {
|
||||
@@ -172,7 +210,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||
break
|
||||
}
|
||||
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
||||
// 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
|
||||
@@ -214,6 +263,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
return s.brainUpdate(ctx, args)
|
||||
case "brain_get":
|
||||
return s.brainGet(ctx, args)
|
||||
case "capture":
|
||||
return s.brainCapture(ctx, args)
|
||||
case "brain_index":
|
||||
return s.brainIndex(ctx, args)
|
||||
case "brain_tunnel":
|
||||
|
||||
Reference in New Issue
Block a user