Files
zip/toolname_test.go
T
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

119 lines
3.4 KiB
Go

package zip
import (
"context"
"encoding/json"
"strings"
"testing"
)
// #4: do MCP tool names ride the same rule as operation IDs?
//
// A tool name is what an agent CALLS and what MCP clients cache, so an
// ordering-dependent one is the same defect as an ordering-dependent operation
// ID. This checks the two halves separately, because they turn out to be
// different questions.
func TestToolNames_RideTheOperationIDRule(t *testing.T) {
// Half one: a definition's OWN typed ops, included twice.
billing := unnamedApp() // one op, id UNDECLARED — see unnamedApp
host := quiet("host")
host.Group("/v1").Use(billing)
host.Group("/admin").Use(billing)
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
raw, names := host.composeTools()
_ = raw
var got []string
for n := range names {
got = append(got, n)
}
if len(got) != 2 {
t.Fatalf("two occurrences produced %d tool names, want 2: %v", len(got), got)
}
for _, want := range []string{"get_invoices_by_id", "get_admin_invoices_by_id"} {
if !names[want] {
t.Errorf("tool name %q missing — own-op tool names must be the occurrence's operation id: %v", want, got)
}
}
// And the rendered list agrees with the registry, one name per operation.
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err != nil {
t.Fatalf("tool list is not an array: %v", err)
}
if len(arr) != len(host.Registry()) {
t.Errorf("tool list has %d entries, registry has %d", len(arr), len(host.Registry()))
}
}
// Half two: a definition included twice must not produce two names for ONE
// callable thing. A plugin's catalogue is forwarded to the plugin's own MCP
// door by name — mcpForward never consults a prefix — so there is exactly one
// backend however many prefixes proxy to it.
func TestToolNames_OnePluginDefinitionIsOneTool(t *testing.T) {
shared := quiet("shared")
shared.plugMu.Lock()
shared.pluginTools = []mcpTool{{name: "sharedTool", raw: json.RawMessage(`{"name":"sharedTool"}`)}}
shared.plugMu.Unlock()
host := quiet("host")
host.Group("/v1").Use(shared)
host.Group("/admin").Use(shared)
// Give the host a route so the composition is buildable.
host.Raw("GET", "/health", func(c *Ctx) error { return nil })
shared.Raw("GET", "/x", func(c *Ctx) error { return nil })
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
n := 0
for _, tl := range host.tools() {
if tl.name == "sharedTool" {
n++
}
}
if n != 1 {
t.Errorf("one plugin definition at two prefixes produced %d tool entries, want 1 — "+
"a catalogue tool is invoked by NAME at the plugin's own door, so two names would "+
"invent a second callable that does not exist", n)
}
}
func TestToolNames_AreNotPositional(t *testing.T) {
namesFor := func(a, b string) []string {
billing := unnamedApp()
host := quiet("host")
host.Group(a).Use(billing)
host.Group(b).Use(billing)
if err := host.Build(); err != nil {
t.Fatalf("build: %v", err)
}
_, set := host.composeTools()
var out []string
for n := range set {
out = append(out, n)
}
return out
}
x, y := namesFor("/v1", "/admin"), namesFor("/admin", "/v1")
sortStrings(x)
sortStrings(y)
if strings.Join(x, ",") != strings.Join(y, ",") {
t.Errorf("tool names depend on composition order: %v vs %v", x, y)
}
}
func sortStrings(s []string) {
for i := range s {
for j := i + 1; j < len(s); j++ {
if s[j] < s[i] {
s[i], s[j] = s[j], s[i]
}
}
}
}
var _ = context.Background