Files
zip/mcp_compose_test.go
T
zeekay 3ab8d5e9be the version every address shares is not part of its name
/v1 is on 2,026 of this estate's 2,473 operations, which is to say it tells none
of them apart. A segment earns its place in an id by distinguishing two
operations; this one is a constant, and it rode four surfaces at once — the
OpenAPI operationId, the MCP tool name an agent reads on every turn, the by-name
call key, and the CLI's spelling — so the constant was paid for four times, most
expensively in a tool list where every token is context not spent on the question.

ID drops it, and only it: the leading segment, only when it is the default. A
later version is the EXCEPTION and keeps its segment, which is both how it stays
honest and how it stays unique — get_agents is /v1/agents and get_v2_agents is
/v2/agents, so the two cannot collapse onto one name the walk would then refuse
to publish. The default is implicit; a departure from it is not.

The pinned names in the suite move with the rule, because they were pinning the
rule. TestServePathAllocsAreChainDepthInvariant fails here and on the base commit
alike — 34 allocs either way, unrelated to naming.
2026-08-12 18:34:12 -07:00

270 lines
9.6 KiB
Go

package zip_test
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// buildPluginBin compiles internal/testplugin to a FILE and returns the path,
// because the catalogue is captured by RUNNING the binary — the build-time step,
// which is exactly where a host's Plugin.Tools comes from.
func buildPluginBin(t testing.TB, version string) string {
t.Helper()
out := filepath.Join(t.TempDir(), "testplugin-"+version)
cmd := exec.Command("go", "build", "-ldflags", "-X main.version="+version, "-o", out, "./internal/testplugin")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if b, err := cmd.CombinedOutput(); err != nil {
t.Skipf("cannot build test plugin (no toolchain?): %v\n%s", err, b)
}
return out
}
// catalogue runs `<bin> tools` — the plugin projecting its own MCP surface.
func catalogue(t testing.TB, bin string) []byte {
t.Helper()
b, err := exec.Command(bin, "tools").Output()
if err != nil {
t.Fatalf("%s tools: %v", bin, err)
}
return b
}
func toolNames(t *testing.T, body string) []string {
t.Helper()
var env struct {
Result struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
} `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(body), &env); err != nil {
t.Fatalf("tools/list is not JSON-RPC: %v\nbody=%s", err, body)
}
names := make([]string, 0, len(env.Result.Tools))
for _, tl := range env.Result.Tools {
if tl.Description == "" {
t.Errorf("tool %q has an EMPTY description — a model paying context for a nameless tool is a silent failure", tl.Name)
}
if len(tl.InputSchema) == 0 {
t.Errorf("tool %q has no inputSchema", tl.Name)
}
names = append(names, tl.Name)
}
return names
}
func running(t *testing.T, app *zip.App) []string {
t.Helper()
var up []string
for _, s := range app.Plugins() {
if s.Running {
up = append(up, s.Name)
}
}
return up
}
// TestMCP_ComposedListWakesNothing is the load-bearing proof of the whole
// feature: a host composing LAZY plugins answers tools/list from their build-time
// catalogues with ZERO child processes started, and a tools/call starts exactly
// the ONE plugin that owns the named tool.
//
// A host that woke every child to answer tools/list would destroy the single
// invariant that makes a large lazy fleet affordable, and tools/list is the
// method an MCP client calls constantly. So the process count is asserted, not
// argued.
func TestMCP_ComposedListWakesNothing(t *testing.T) {
bin := buildPluginBin(t, "v1")
tools := catalogue(t, bin)
if !strings.Contains(string(tools), "get_demo_version") {
t.Fatalf("plugin catalogue does not name its own op: %s", tools)
}
// A host with NO typed ops of its own — the hanzoai/cloud shape — serving the
// door at a versioned public path.
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
for _, name := range []string{"demo", "other"} {
p := zip.Plugin{Name: name, Path: bin, Lazy: true}
if name == "demo" {
p.Tools = tools // only one of them declares a catalogue
}
app.Use(must(zip.Load(p, "/v1/"+name)))
}
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
if up := running(t, app); len(up) != 0 {
t.Fatalf("a lazy Load started something: %v", up)
}
// tools/list, three times over — an MCP client calls it constantly.
var names []string
for i := 0; i < 3; i++ {
status, body := call(t, app, "POST", "/v1/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if status != 200 {
t.Fatalf("tools/list: status %d body=%s", status, body)
}
names = toolNames(t, body)
}
if up := running(t, app); len(up) != 0 {
t.Fatalf("tools/list WOKE %v — the list must cost zero processes", up)
}
if len(names) == 0 {
t.Fatal("tools/list is empty: the host has no ops of its own, so the plugin catalogue is the whole surface")
}
var found bool
for _, n := range names {
if n == "get_demo_version" {
found = true
}
}
if !found {
t.Fatalf("the plugin's op is not on the host's door: %v", names)
}
// Sorted, because the same projection is committed as an artifact.
for i := 1; i < len(names); i++ {
if names[i-1] > names[i] {
t.Fatalf("tools/list is not sorted by name: %v", names)
}
}
// A name nobody owns never reaches a process either.
_, body := call(t, app, "POST", "/v1/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"nope","arguments":{}}}`)
if !strings.Contains(body, "unknown tool") {
t.Fatalf("unknown tool should be -32602: %s", body)
}
if up := running(t, app); len(up) != 0 {
t.Fatalf("an unknown tools/call woke %v", up)
}
// tools/call — the ONE trigger, and it wakes ONE child.
status, body := call(t, app, "POST", "/v1/mcp",
`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_demo_version","arguments":{}}}`)
if status != 200 {
t.Fatalf("tools/call: status %d body=%s", status, body)
}
if !strings.Contains(body, `\"version\":\"v1\"`) {
t.Fatalf("tools/call did not reach the plugin's own handler: %s", body)
}
up := running(t, app)
if len(up) != 1 || up[0] != "demo" {
t.Fatalf("tools/call started %v — exactly one owner, and only the owner", up)
}
_ = app.Shutdown()
}
// TestMCP_DuplicateToolNameRefusedAtLoad pins the Load-time analogue of a
// duplicate prefix claim: a tool NAME is dispatch, so two owners make it
// unroutable and the composition must fail with both names rather than silently
// forward every call to whichever loaded first.
func TestMCP_DuplicateToolNameRefusedAtBuild(t *testing.T) {
bin := buildPluginBin(t, "v1")
tools := catalogue(t, bin)
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(must(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: tools}, "/v1/a")))
app.Use(must(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true, Tools: tools}, "/v1/b")))
// Refused at BUILD, not at Load: two definitions carry their own catalogues
// and cannot see each other at construction, so only the composition can.
// Same reason address conflicts moved to the walk when App.claim was deleted.
err := app.Build()
if err == nil {
t.Fatal("two plugins claiming one tool name must be refused")
}
if !strings.Contains(err.Error(), "get_demo_") ||
!strings.Contains(err.Error(), `"a"`) || !strings.Contains(err.Error(), `"b"`) {
t.Fatalf("the refusal must name the tool and BOTH claimants: %v", err)
}
}
// TestMCP_MalformedCatalogueRefusedAtLoad — a catalogue that is not a tool array
// is a build bug, and it fails at Load rather than at the first tools/list.
func TestMCP_MalformedCatalogueRefusedAtLoad(t *testing.T) {
bin := buildPluginBin(t, "v1")
_, err := zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: []byte(`{"not":"an array"}`)}, "/v1/a")
if err == nil || !strings.Contains(err.Error(), "not a JSON array") {
t.Fatalf("a malformed catalogue must be refused at Load: %v", err)
}
}
// TestMCP_NoOpsNoCatalogueNoDoor — a host with neither is not an MCP server, and
// must not answer the door with an empty tool list a client would cache.
func TestMCP_NoOpsNoCatalogueNoDoor(t *testing.T) {
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
status, _ := call(t, app, "POST", "/v1/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if status != 404 {
t.Fatalf("no ops and no catalogue must serve no door, got %d", status)
}
}
// TestMCP_LoadAfterListenIsOnTheList — a host may compose a plugin at run time, and
// the served list is re-rendered rather than frozen at boot. Without this a plugin
// added after Listen is invisible until a restart, which is not a state anything can
// see or explain.
func TestMCP_LoadAfterListenIsOnTheList(t *testing.T) {
bin := buildPluginBin(t, "v1")
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
// One plugin at boot, so the door installs; a second one after.
app.Use(must(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: catalogue(t, bin)}, "/v1/a")))
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
before := len(toolNames(t, listBody(t, app)))
// A second catalogue with a distinct name, as a differently-built plugin would
// have — the duplicate case is refused, and is proven separately.
// Include, not Use: the app is serving, so a composition change goes through
// a generation — it is validated and swapped, or the live one keeps serving.
h, herr := zip.Serve(app, "127.0.0.1:0")
if herr != nil {
t.Fatalf("Serve: %v", herr)
}
defer func() { _ = h.Close() }()
if err := h.Include(must(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true,
Tools: []byte(`[{"name":"b_extra","description":"Added after Listen.","inputSchema":{"type":"object"}}]`)}, "/v1/b"))); err != nil {
t.Fatalf("Include after Listen: %v", err)
}
names := toolNames(t, listBody(t, app))
if len(names) != before+1 {
t.Fatalf("a plugin Load'ed after Listen is not on the list: %v", names)
}
var found bool
for _, n := range names {
if n == "b_extra" {
found = true
}
}
if !found {
t.Fatalf("b_extra missing: %v", names)
}
if up := running(t, app); len(up) != 0 {
t.Fatalf("re-rendering the list woke %v", up)
}
}
func listBody(t *testing.T, app *zip.App) string {
t.Helper()
status, body := call(t, app, "POST", "/v1/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
if status != 200 {
t.Fatalf("tools/list: %d %s", status, body)
}
return body
}