mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A typed op could only take one JSON value and answer one, so uploads, signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE, WebSockets, OAuth redirects and upstream relays stayed untyped routes with no schema, tool, command or SDK method. Each is now a typed op and its REST wire is unchanged. Requests: a zip.Body field takes the body and Content-Type exactly as sent; an In implementing Parser decodes itself; form: fields and *zip.File parts bind url-encoded and multipart forms; cookie: binds a request cookie as an in: cookie parameter. Consumes names the media. Answers: zip.Body (bytes, filename, its own status and headers, a Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T] (events with keep-alive), Socket[M] (an upgrade after the contract runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces, and unions: a type whose OneOf method's results are the alternatives, with Or[A,B] as the stock one filing each under the status it states. Projections follow from the manifest: the document publishes binary media, form schemas, x-events, x-socket with 101, 3xx with Location and oneOf; MCP takes bytes as base64 and returns bytes, text or collected events as tool content, listing a socket op in _meta as no tool; the CLI reads --body @file|-, --field name=@file and --out, prints events as they arrive and bridges a socket to stdin and stdout. tools/list is ProjectMCP over the manifest, the projection MCPTools already was. Prose reaches promoted fields (filed under the declaring struct), a type defined over another struct, and the type arguments of zip's kinds and OneOf results; an instantiated generic is published under its name followed by its argument names instead of an illegal component key. The unused reflect-only document and tool builders go.
119 lines
3.4 KiB
Go
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
|