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}, "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. 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) }