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
92 changes: 92 additions & 0 deletions cmd/harness/ambient_mcp_smoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/majorcontext/harness/config"
"github.com/majorcontext/harness/engine"
"github.com/majorcontext/harness/message"
"github.com/majorcontext/harness/provider"
)

func TestAmbientMCPSourcesConfigToEngineSmoke(t *testing.T) {
if _, err := os.Stat("/tmp/box-skills-mcp"); errors.Is(err, os.ErrNotExist) {
t.Skip("box-skills-mcp fixture is unavailable")
} else if err != nil {
t.Fatal(err)
}
var gotPath, gotAuth string
fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"content_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","entries":[{"skill_id":"skill_test","revision_id":"revision_test","qualified_label":"shared:test/review","name":"review","description":"Review code."}]}`)
}))
t.Cleanup(fixture.Close)

workDir := t.TempDir()
t.Setenv("HARNESS_CONFIG", filepath.Join(t.TempDir(), "config.json"))
configPath := filepath.Join(workDir, ".harness.json")
configJSON := fmt.Sprintf(`{
"mcp_servers": {"boxes": {
"command": ["/tmp/box-skills-mcp"],
"env": ["BOXES_SKILLS_URL=%s", "BOXES_SKILLS_TOKEN=fake", "BOX_ID=box_fixture"]
}},
"ambient_mcp_sources": {"skills": {"server": "boxes", "tool": "list_adopted_skills"}}
}`, fixture.URL)
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := config.LoadProject(workDir)
if err != nil {
t.Fatalf("LoadProject: %v", err)
}

mcpMgr := buildMCPManager(cfg.MCPServers)
t.Cleanup(func() { closeMCPManager(mcpMgr) })
prov := &scriptedProvider{name: "test"}
sess := engine.NewSession(engine.Config{
Providers: provider.Registry{"test": prov},
Model: message.ModelRef{Provider: "test", Model: "m1"},
WorkDir: workDir,
MCP: mcpRegistry(mcpMgr),
AmbientMCPSources: ambientMCPSources(cfg.AmbientMCPSources),
RequireContextWindow: false,
Instructions: &engine.InstructionsConfig{Disabled: true},
SkillsDirs: []string{},
})
if _, err := sess.Prompt(context.Background(), "hello"); err != nil {
t.Fatalf("Prompt: %v", err)
}

if gotPath != "/v1/boxes/box_fixture/skills" {
t.Errorf("fixture path = %q, want /v1/boxes/box_fixture/skills", gotPath)
}
if gotAuth != "Bearer fake" {
t.Errorf("fixture Authorization = %q, want Bearer fake", gotAuth)
}
if len(prov.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(prov.requests))
}
var contextText string
for _, msg := range prov.requests[0].Messages {
for _, part := range msg.Parts {
if ambient, ok := part.(*message.EngineContext); ok {
contextText += ambient.Text
}
}
}
for _, want := range []string{"Adopted skills catalog (skills)", "skill_test", "Review code."} {
if !strings.Contains(contextText, want) {
t.Errorf("EngineContext = %q, want %q", contextText, want)
}
}
}
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")
}
}
45 changes: 45 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 @@ -1637,9 +1663,28 @@ func merge(base, over *Config) *Config {
m[k] = copyMCPServerSpec(v)
}
out.MCPServers = m
for _, source := range base.AmbientMCPSources {
if server, ok := base.MCPServers[source.Server]; ok {
m[source.Server] = copyMCPServerSpec(server)
}
}
} 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
Loading