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>
106 lines
4.3 KiB
Go
106 lines
4.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
|
)
|
|
|
|
// principalKey is the context key under which the authenticated principal
|
|
// (re-derived in ServeHTTP) is stashed for the capture tool.
|
|
type principalKeyT struct{}
|
|
|
|
var principalKey principalKeyT
|
|
|
|
type principalInfo struct {
|
|
principal string
|
|
viaStatic bool
|
|
}
|
|
|
|
func withPrincipal(ctx context.Context, principal string, viaStatic bool) context.Context {
|
|
return context.WithValue(ctx, principalKey, principalInfo{principal: principal, viaStatic: viaStatic})
|
|
}
|
|
|
|
// captureToolDescriptor is the tools/list entry for the capture relay.
|
|
// Appended only when WithCapture has wired the tool.
|
|
func captureToolDescriptor() map[string]any {
|
|
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
insightItem := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"text": str("the insight body"), "wing": str("brain wing"),
|
|
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
|
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
|
},
|
|
"required": []string{"text", "wing", "hall"},
|
|
}
|
|
ticketItem := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
|
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
|
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
|
},
|
|
"required": []string{"repo", "action"},
|
|
}
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"context": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
|
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
|
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
|
},
|
|
},
|
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
|
"summary": map[string]any{"type": "object", "properties": map[string]any{
|
|
"title": str("summary title"), "body": str("summary body"),
|
|
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
}},
|
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(schema)
|
|
return map[string]any{
|
|
"name": "capture",
|
|
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
|
"inputSchema": json.RawMessage(b),
|
|
}
|
|
}
|
|
|
|
// brainCapture is the MCP capture tool: the #55 relay for MCP-native
|
|
// harnesses. It re-uses the same CaptureService, principal-derivation, and
|
|
// origin resolver as POST /capture — only the transport differs. It holds
|
|
// no state and retains nothing beyond the I5 audit record.
|
|
func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
if s.capture == nil {
|
|
return nil, fmt.Errorf("capture tool not configured")
|
|
}
|
|
info, ok := ctx.Value(principalKey).(principalInfo)
|
|
if !ok || info.principal == "" {
|
|
// No authenticated principal ⇒ cannot derive origin ⇒ cannot gate.
|
|
return nil, fmt.Errorf("capture requires an authenticated principal")
|
|
}
|
|
|
|
in, err := capturehttp.DecodeRequest(args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid capture request: %w", err)
|
|
}
|
|
// Principal and origin are server-derived — never taken from the body.
|
|
in.Context.Principal = info.principal
|
|
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
|
|
|
rec, err := s.capture.svc.Capture(ctx, in)
|
|
if err != nil {
|
|
// Surface I1/I5 refusals and validation failures verbatim; errors.Is
|
|
// markers (ErrSovereigntyRefused / ErrAuditUnavailable) ride in the message.
|
|
return nil, err
|
|
}
|
|
return json.Marshal(rec)
|
|
}
|