Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/harness/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions cmd/harness/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions config/ambient_mcp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
40 changes: 40 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions docs/engine-request-cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 166 additions & 0 deletions engine/ambient_mcp.go
Original file line number Diff line number Diff line change
@@ -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)) }
50 changes: 50 additions & 0 deletions engine/ambient_mcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading