From f69be8244a427cdced7c05075478b1ec5512d335 Mon Sep 17 00:00:00 2001 From: andybons Date: Fri, 11 Sep 2026 20:30:33 +0000 Subject: [PATCH] feat(engine): load adopted skills from mcp --- cmd/harness/main.go | 2 + cmd/harness/mcp.go | 11 ++ config/ambient_mcp_test.go | 33 +++++ config/config.go | 40 ++++++ docs/engine-request-cycle.md | 17 +++ engine/ambient_mcp.go | 166 +++++++++++++++++++++++++ engine/ambient_mcp_test.go | 50 ++++++++ engine/engine.go | 10 ++ engine/snapshot_field_coverage_test.go | 1 + 9 files changed, 330 insertions(+) create mode 100644 config/ambient_mcp_test.go create mode 100644 engine/ambient_mcp.go create mode 100644 engine/ambient_mcp_test.go diff --git a/cmd/harness/main.go b/cmd/harness/main.go index f7bf88f4..3772b4ff 100644 --- a/cmd/harness/main.go +++ b/cmd/harness/main.go @@ -729,6 +729,7 @@ func runCmd(args []string) error { AgentDefsDirs: agentDefsDirs(cfg, opts.agentDefsDirs, workDir), Hooks: pluginHooks(host), MCP: mcpRegistry(mcpMgr), + AmbientMCPSources: ambientMCPSources(cfg.AmbientMCPSources), MCPToolLoading: mcpToolLoading(cfg.MCPToolLoading), MCPToolLoadingThreshold: cfg.MCPToolLoadingThreshold, MCPToolLoadingByServer: mcpToolLoadingByServer(cfg.MCPServers), @@ -1572,6 +1573,7 @@ func serveCmd(args []string) error { AgentDefsDirs: agentDefsDirs(cfg, agentDefDirs, workDir), Hooks: pluginHooks(pluginHost), MCP: mcpRegistry(mcpMgr), + AmbientMCPSources: ambientMCPSources(cfg.AmbientMCPSources), MCPToolLoading: mcpToolLoading(cfg.MCPToolLoading), MCPToolLoadingThreshold: cfg.MCPToolLoadingThreshold, MCPToolLoadingByServer: mcpToolLoadingByServer(cfg.MCPServers), diff --git a/cmd/harness/mcp.go b/cmd/harness/mcp.go index afd71d27..1f8ceeca 100644 --- a/cmd/harness/mcp.go +++ b/cmd/harness/mcp.go @@ -84,6 +84,17 @@ func mcpToolLoadingByServer(servers map[string]config.MCPServerSpec) map[string] return out } +func ambientMCPSources(sources map[string]config.AmbientMCPSourceSpec) map[string]engine.AmbientMCPSource { + if len(sources) == 0 { + return nil + } + out := make(map[string]engine.AmbientMCPSource, len(sources)) + for key, source := range sources { + out[key] = engine.AmbientMCPSource{Server: source.Server, Tool: source.Tool, Label: source.Label} + } + return out +} + // mcpRegistry adapts a possibly-nil *engine.MCPManager to engine.MCPRegistry, // the same typed-nil guard pluginHooks applies to *plugin.Host: assigning a // typed-nil *engine.MCPManager directly to an engine.MCPRegistry-typed diff --git a/config/ambient_mcp_test.go b/config/ambient_mcp_test.go new file mode 100644 index 00000000..315c4d42 --- /dev/null +++ b/config/ambient_mcp_test.go @@ -0,0 +1,33 @@ +package config + +import "testing" + +func TestMergeAmbientMCPSourcesPreservesUserSource(t *testing.T) { + base := &Config{ + MCPServers: map[string]MCPServerSpec{"box-skills": {URL: "https://example.test/mcp"}}, + AmbientMCPSources: map[string]AmbientMCPSourceSpec{ + "skills": {Server: "box-skills", Tool: "list_adopted_skills", Label: "adopted skills"}, + }, + } + over := &Config{AmbientMCPSources: map[string]AmbientMCPSourceSpec{ + "skills": {Server: "other", Tool: "other"}, + "extra": {Server: "box-skills", Tool: "list_extra"}, + }} + got, err := mergeAndValidate(base, over) + if err != nil { + t.Fatal(err) + } + if got.AmbientMCPSources["skills"].Server != "box-skills" { + t.Fatalf("user source was replaced: %+v", got.AmbientMCPSources["skills"]) + } + if got.AmbientMCPSources["extra"].Tool != "list_extra" { + t.Fatalf("project source was not added: %+v", got.AmbientMCPSources) + } +} + +func TestAmbientMCPSourcesRequireKnownServerAfterMerge(t *testing.T) { + _, err := mergeAndValidate(&Config{}, &Config{AmbientMCPSources: map[string]AmbientMCPSourceSpec{"skills": {Server: "missing", Tool: "list_adopted_skills"}}}) + if err == nil { + t.Fatal("want missing server error") + } +} diff --git a/config/config.go b/config/config.go index e3715b9f..5c030b90 100644 --- a/config/config.go +++ b/config/config.go @@ -113,6 +113,9 @@ type Config struct { // make field-by-field merging (as Provider gets) more confusing than // useful here. MCPServers map[string]MCPServerSpec `json:"mcp_servers,omitempty"` + // AmbientMCPSources declares MCP catalogs injected as trusted runtime + // context. A project can add keys but cannot replace a user source. + AmbientMCPSources map[string]AmbientMCPSourceSpec `json:"ambient_mcp_sources,omitempty"` // Processes declares named dev/support processes the engine can // manage (start/stop/restart/status/logs) via the "process" session // tool and the server's /process endpoints (see package engine's @@ -348,6 +351,14 @@ type ProcessSpec struct { ReadyTimeoutS int `json:"ready_timeout_s,omitempty"` } +// AmbientMCPSourceSpec identifies one configured MCP tool that supplies an +// ambient catalog. Server and Tool are required. Label defaults to the map key. +type AmbientMCPSourceSpec struct { + Server string `json:"server"` + Tool string `json:"tool"` + Label string `json:"label,omitempty"` +} + // MCPServerSpec configures one MCP server (package mcp's client, wired by // package engine). Exactly one of Command (a stdio server: argv, env, // working directory) or URL (a Streamable HTTP server: endpoint, headers) @@ -1120,6 +1131,18 @@ func validateMCPServers(servers map[string]MCPServerSpec) error { return nil } +func validateAmbientMCPSources(sources map[string]AmbientMCPSourceSpec, servers map[string]MCPServerSpec) error { + for key, source := range sources { + if key == "" || source.Server == "" || source.Tool == "" { + return fmt.Errorf("ambient_mcp_sources.%s requires server and tool", key) + } + if _, ok := servers[source.Server]; !ok { + return fmt.Errorf("ambient_mcp_sources.%s references unknown mcp server %q", key, source.Server) + } + } + return nil +} + // validateProcesses fails loudly on a process entry that cannot possibly be // wired: the map key naming it must be non-empty (it is the identity a // caller uses to start/stop/restart/status/logs it โ€” same "cannot possibly @@ -1351,6 +1374,9 @@ func mergeAndValidate(base, over *Config) (*Config, error) { if err := validateAppendSystemPromptArgs(out); err != nil { return nil, fmt.Errorf("config: %w", err) } + if err := validateAmbientMCPSources(out.AmbientMCPSources, out.MCPServers); err != nil { + return nil, fmt.Errorf("config: %w", err) + } return out, nil } @@ -1640,6 +1666,20 @@ func merge(base, over *Config) *Config { } else { out.MCPServers = nil } + if n := len(base.AmbientMCPSources) + len(over.AmbientMCPSources); n > 0 { + m := make(map[string]AmbientMCPSourceSpec, n) + for k, v := range base.AmbientMCPSources { + m[k] = v + } + for k, v := range over.AmbientMCPSources { + if _, exists := m[k]; !exists { + m[k] = v + } + } + out.AmbientMCPSources = m + } else { + out.AmbientMCPSources = nil + } if n := len(base.Processes) + len(over.Processes); n > 0 { m := make(map[string]ProcessSpec, n) for k, v := range base.Processes { diff --git a/docs/engine-request-cycle.md b/docs/engine-request-cycle.md index 425696fb..b58709a8 100644 --- a/docs/engine-request-cycle.md +++ b/docs/engine-request-cycle.md @@ -239,6 +239,23 @@ never written to the session log โ€” a resumed session rediscovers them. Config `skills_dirs` (array; a non-empty project value overrides the user value entirely) and the repeatable `-skills-dir` run/serve flag drive it. +## Adopted personal skills from MCP + +`ambient_mcp_sources` maps a source key to its MCP `server`, discovery `tool`, +and optional display `label`. Before each run, Harness calls each source tool +with `{}` under a five-second deadline. A valid result is the version-1 catalog +envelope with a hash and entries carrying `id` and `revision_id`. + +Harness renders the catalog as runtime `EngineContext`, never as a system +segment. Each source has its own append-only ambient pin. The source's existing +MCP `load_skill` tool loads an exact advertised revision; Harness does not add +another tool. An unavailable, malformed, or oversized catalog renders an +explicit unavailable notice and the run continues with repository skills. + +Claude Code delegated turns do not receive ambient MCP catalogs. They bypass the +native request assembly and must remain disabled behind the Boxes release gate +until the CLI path supports the same pinning contract. + ## Tool-batching guidance The engine executes one assistant message's tool calls concurrently diff --git a/engine/ambient_mcp.go b/engine/ambient_mcp.go new file mode 100644 index 00000000..5522707f --- /dev/null +++ b/engine/ambient_mcp.go @@ -0,0 +1,166 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + ambientMCPSourceTimeout = 5 * time.Second + ambientMCPSourceMaxBytes = 256 * 1024 + ambientMCPKindPrefix = "ambient_mcp:" +) + +// AmbientMCPSource configures one live MCP catalog. Server and Tool are +// required by config validation. Label defaults to the source key. +type AmbientMCPSource struct { + Server string + Tool string + Label string +} + +type ambientMCPEntry struct { + ID string `json:"id"` + RevisionID string `json:"revision_id"` + QualifiedLabel string `json:"qualified_label"` + Name string `json:"name"` + Description string `json:"description"` +} + +type ambientMCPCatalog struct { + Version int `json:"version"` + Hash string `json:"hash"` + Entries []ambientMCPEntry `json:"entries"` +} + +type ambientMCPSourceSnapshot struct { + catalog ambientMCPCatalog + unavailable bool +} + +func (s *Session) refreshAmbientMCPSources(ctx context.Context) { + if len(s.cfg.AmbientMCPSources) == 0 { + return + } + out := make(map[string]ambientMCPSourceSnapshot, len(s.cfg.AmbientMCPSources)) + for key, source := range s.cfg.AmbientMCPSources { + out[key] = s.loadAmbientMCPSource(ctx, source) + } + s.mu.Lock() + s.ambientMCPSources = out + s.mu.Unlock() +} + +func (s *Session) loadAmbientMCPSource(ctx context.Context, source AmbientMCPSource) ambientMCPSourceSnapshot { + if s.cfg.MCP == nil { + return ambientMCPSourceSnapshot{unavailable: true} + } + callCtx, cancel := context.WithTimeout(ctx, ambientMCPSourceTimeout) + defer cancel() + parts, isErr, err := s.cfg.MCP.CallServerTool(callCtx, source.Server, source.Tool, json.RawMessage(`{}`)) + if err != nil || isErr { + return ambientMCPSourceSnapshot{unavailable: true} + } + text := parts.Text() + if len(text) > ambientMCPSourceMaxBytes { + return ambientMCPSourceSnapshot{unavailable: true} + } + var catalog ambientMCPCatalog + if err := json.Unmarshal([]byte(text), &catalog); err != nil || !sanitizeAmbientMCPCatalog(&catalog) { + return ambientMCPSourceSnapshot{unavailable: true} + } + sort.Slice(catalog.Entries, func(i, j int) bool { + a, b := catalog.Entries[i], catalog.Entries[j] + if a.ID != b.ID { + return a.ID < b.ID + } + if a.RevisionID != b.RevisionID { + return a.RevisionID < b.RevisionID + } + if a.QualifiedLabel != b.QualifiedLabel { + return a.QualifiedLabel < b.QualifiedLabel + } + return a.Name < b.Name + }) + return ambientMCPSourceSnapshot{catalog: catalog} +} + +func sanitizeAmbientMCPCatalog(c *ambientMCPCatalog) bool { + if c.Version != 1 || c.Hash == "" || runeLen(c.Hash) > 256 { + return false + } + seen := make(map[string]bool, len(c.Entries)) + for i := range c.Entries { + e := &c.Entries[i] + e.ID = sanitizeAmbientMCPText(e.ID) + e.RevisionID = sanitizeAmbientMCPText(e.RevisionID) + e.QualifiedLabel = sanitizeAmbientMCPText(e.QualifiedLabel) + e.Name = sanitizeAmbientMCPText(e.Name) + e.Description = sanitizeAmbientMCPText(e.Description) + if e.ID == "" || e.RevisionID == "" || e.QualifiedLabel == "" || e.Name == "" || e.Description == "" || runeLen(e.ID) > 256 || runeLen(e.RevisionID) > 256 || runeLen(e.QualifiedLabel) > 256 || runeLen(e.Name) > 64 || runeLen(e.Description) > 1024 { + return false + } + key := e.ID + "\x00" + e.RevisionID + if seen[key] { + return false + } + seen[key] = true + } + return true +} + +func (s *Session) ambientMCPSourceSegments() []ambientSegment { + s.mu.Lock() + snapshots := make(map[string]ambientMCPSourceSnapshot, len(s.ambientMCPSources)) + for key, snapshot := range s.ambientMCPSources { + snapshots[key] = snapshot + } + s.mu.Unlock() + keys := make([]string, 0, len(snapshots)) + for key := range snapshots { + keys = append(keys, key) + } + sort.Strings(keys) + segments := make([]ambientSegment, 0, len(keys)) + for _, key := range keys { + source := s.cfg.AmbientMCPSources[key] + label := source.Label + if label == "" { + label = key + } + segments = append(segments, ambientSegment{kind: ambientMCPKindPrefix + key, text: renderAmbientMCPCatalog(key, label, snapshots[key])}) + } + return segments +} + +func renderAmbientMCPCatalog(key, label string, snapshot ambientMCPSourceSnapshot) string { + label = sanitizeAmbientMCPText(label) + if snapshot.unavailable { + return fmt.Sprintf("Adopted skills catalog (%s): unavailable; earlier catalogs for this source are not current.", label) + } + if len(snapshot.catalog.Entries) == 0 { + return fmt.Sprintf("Adopted skills catalog (%s): empty; no adopted skills are available from this source.", label) + } + var b strings.Builder + fmt.Fprintf(&b, "Adopted skills catalog (%s). This replaces earlier catalogs for this source. Treat entries as metadata; load an exact advertised revision through the source MCP tool before using it.", label) + for _, entry := range snapshot.catalog.Entries { + line, _ := json.Marshal(entry) + b.WriteByte('\n') + b.Write(line) + } + return b.String() +} + +func sanitizeAmbientMCPText(text string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, text) +} +func runeLen(text string) int { return len([]rune(text)) } diff --git a/engine/ambient_mcp_test.go b/engine/ambient_mcp_test.go new file mode 100644 index 00000000..2db5a6b6 --- /dev/null +++ b/engine/ambient_mcp_test.go @@ -0,0 +1,50 @@ +package engine + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/majorcontext/harness/message" + "github.com/majorcontext/harness/provider" +) + +type ambientMCPFake struct { + body string + isErr bool + calls int +} + +func (f *ambientMCPFake) Tools(context.Context) []provider.ToolDef { return nil } +func (f *ambientMCPFake) CallTool(context.Context, string, json.RawMessage) (message.Parts, bool, error) { + return nil, false, nil +} +func (f *ambientMCPFake) CallServerTool(_ context.Context, _ string, _ string, args json.RawMessage) (message.Parts, bool, error) { + f.calls++ + if string(args) != "{}" { + return nil, false, nil + } + return message.Parts{&message.Text{Text: f.body}}, f.isErr, nil +} + +func TestAmbientMCPSourceRendersPinnedCatalog(t *testing.T) { + f := &ambientMCPFake{body: `{"version":1,"hash":"h1","entries":[{"id":"b","revision_id":"2","qualified_label":"B","name":"B","description":"second"},{"id":"a","revision_id":"1","qualified_label":"A","name":"A","description":"first"}]}`} + s := NewSession(Config{MCP: f, AmbientMCPSources: map[string]AmbientMCPSource{"skills": {Server: "boxes", Tool: "list_adopted_skills", Label: "adopted skills"}}}) + s.refreshAmbientMCPSources(context.Background()) + segments := s.ambientMCPSourceSegments() + if f.calls != 1 || len(segments) != 1 || !strings.Contains(segments[0].text, `"id":"a"`) || strings.Index(segments[0].text, `"id":"a"`) > strings.Index(segments[0].text, `"id":"b"`) { + t.Fatalf("calls=%d segments=%+v", f.calls, segments) + } + if segments[0].kind != "ambient_mcp:skills" { + t.Errorf("kind=%q", segments[0].kind) + } +} +func TestAmbientMCPSourceUnavailableContinues(t *testing.T) { + f := &ambientMCPFake{isErr: true} + s := NewSession(Config{MCP: f, AmbientMCPSources: map[string]AmbientMCPSource{"skills": {Server: "boxes", Tool: "list_adopted_skills"}}}) + s.refreshAmbientMCPSources(context.Background()) + if got := s.ambientMCPSourceSegments()[0].text; !strings.Contains(got, "unavailable") { + t.Errorf("%q", got) + } +} diff --git a/engine/engine.go b/engine/engine.go index 5856fc81..081a878b 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -682,6 +682,10 @@ type Config struct { // with no separate config flag, unlike GoalTool below. MCP MCPRegistry + // AmbientMCPSources declares MCP catalogs injected as trusted ambient + // context. The key identifies one independent append-only catalog stream. + AmbientMCPSources map[string]AmbientMCPSource + // MCPToolLoading selects when this session defers MCP tool SCHEMAS // instead of registering every one of them on every request (see // mcp_lazy.go and docs/design/mcp-lazy-tools.md). The zero value is @@ -1343,6 +1347,10 @@ type Session struct { skillsSeg string skillsErr error + // ambientMCPSources is each source's current per-run snapshot. The + // rendered text reaches the request only through append-only ambient pins. + ambientMCPSources map[string]ambientMCPSourceSnapshot + // Goal-loop state (see goal.go). goalActive is set while a goal is set but // neither achieved nor cleared; goalCondition holds the current goal's // completion condition. Restored on LoadSession from the goal.* records in @@ -2854,6 +2862,7 @@ func (s *Session) promptWithOrigin(ctx context.Context, text string, origin stri s.emitSessionError(err) return nil, err } + s.refreshAmbientMCPSources(ctx) // Automatic compaction check (docs/design/context-compaction.md ยง1): // runs on every call, bare or goal-loop-driven alike, since PursueGoal // drives everything through Prompt. Deliberately BEFORE the incoming @@ -3292,6 +3301,7 @@ func (s *Session) streamTurn(ctx context.Context, attempt int) (*message.Message {ambientKindGoal, goalParkedSegment(s), "[goal: no longer parked.]"}, {ambientKindIdentity, identityStatusSegment(s.cfg.EngineVersion, s.cfg.StartedAt, s.cfg.SessionSync), ""}, } + segs = append(segs, s.ambientMCPSourceSegments()...) // Unlike the four segments above, this one CHECKS OUT pending // notifications rather than idempotently recomputing a status string โ€” // see checkoutTaskNotificationsSegment's doc comment. Committing them diff --git a/engine/snapshot_field_coverage_test.go b/engine/snapshot_field_coverage_test.go index ac44dcff..ab905d13 100644 --- a/engine/snapshot_field_coverage_test.go +++ b/engine/snapshot_field_coverage_test.go @@ -113,6 +113,7 @@ var snapshotExcludedSessionFields = map[string]string{ "skillsLoaded": "lazy discovery cache gate, same pattern as instrLoaded", "skillsSeg": "lazy discovery cache payload, same pattern as instrLoaded", "skillsErr": "lazy discovery cache error, same pattern as instrLoaded", + "ambientMCPSources": "per-Prompt ambient MCP snapshots; live ambient pins remain the append-only request representation", "goalGen": "explicitly documented \"Deliberately runtime-only: never persisted ... never restored on LoadSession\"", "goalParked": "explicitly documented \"Deliberately runtime-only: never persisted, never folded by LoadSession\"", "goalParkedReason": "same explicit exclusion as goalParked, same doc comment",