mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
The typed-op registry gets its fourth projection. An aggregator that reaches
another app today either imports its package — dragging in that app's whole
dependency graph, which is what turns a fleet of small services into one
mega-binary — or hand-writes a client against its URLs, which is the schema
copied into a second place to drift. Neither is necessary: the op already has
a stable name, and now that name is callable.
c, err := zip.DialApp("flags") // $ZIP_RUNTIME_DIR/flags.sock
out, err := zip.Call[BoolIn, BoolOut](ctx, c, "flags_bool", &in)
The plane is an ordinary route (/.well-known/zip/op/:op), so it rides every
transport Listen was given and inherits the app's middleware, error handler,
validator and Authorizer — exposed exactly as much as the REST routes and no
more. It resolves through opByName + op.invoke, the same pair tools/call runs,
without MCP's agent-facing envelope. Errors cross whole: the wire form is what
errorHandler already writes, so errors.As sees the *HTTPError the callee
returned, status intact. A void op yields (nil, nil).
Generic, not generated, deliberately: a generated per-op Go client would put a
second copy of the schema in the caller's repo on its own regeneration
schedule — the drift this plane removes, one directory over — and would be a
fourth client generator racing openapi-generator, which already builds the
published SDKs from the OpenAPI projection.
ONE socket-path scheme, used by both halves so they cannot drift:
SocketPath(name) = RuntimeDir() + "/" + name + ".sock"
RuntimeDir() = $ZIP_RUNTIME_DIR | $XDG_RUNTIME_DIR/zip | /run/zip
A service serves at zip.Addr(zip.SocketPath("flags")), a caller reaches it with
zip.DialApp("flags"). socketIn states the shape once; the plugin loader names a
child's private socket with the same function. Listen creates the directory
0700 when missing, so the canonical path works on a fresh host.
Identity is never forged, because the caller never states it. WHO the request
is for is the gateway's assertion, forwarded by Ctx.Forward and read by
CallerOf; WHAT is calling is SO_PEERCRED, read by PeerOf — the peer does not
send it, the kernel answers on its behalf. nil means "cannot attest", so a
check fails closed rather than reading a zero Peer as root.
MCPTools() exposes the tool surface the same way OpenAPISpec() exposes the
document, so a host can compose plugins' tools in-process without a round trip.
openapi.go now reads operationId from opName, the one place that rule lives,
instead of re-deriving it inline — all four projections address an op by the
same token.
Cost: Benchmark_TypedRoute/typed 28 -> 29 allocs/op (the one context value that
makes CallerOf and PeerOf possible). The untyped path is unchanged at 27 and
still pinned at one heap value per request.
Proven end to end: a real plugin on a unix socket, a client dialing it, a typed
round trip with no HTTP in the path — the reply frame is decoded with zaphttp's
own codec, HTTP/1.1 text gets no HTTP answer, and the peer credential the
handler observes is proof the transport was unix. Full suite green with
CGO_ENABLED=0 and under -race.
Co-authored-by: Hanzo Dev <[email protected]>
167 lines
6.2 KiB
Go
167 lines
6.2 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
type quoteIn struct {
|
|
Symbol string `json:"symbol" validate:"required"`
|
|
Venue string `json:"venue"`
|
|
}
|
|
type quoteOut struct {
|
|
Cents int `json:"cents"`
|
|
}
|
|
|
|
type fillIn struct {
|
|
OrderID string `json:"order_id" validate:"required"`
|
|
Qty int `json:"qty"`
|
|
}
|
|
type fillOut struct {
|
|
Filled bool `json:"filled"`
|
|
}
|
|
|
|
// TestMCPTools_AreTheRegistryProjected proves the MCP surface is derived, not
|
|
// authored: an app registers TWO typed ops and its entire tool list — names,
|
|
// input schemas and descriptions — falls out of that registry with no per-app
|
|
// MCP code and no transport in the way. App.MCPTools() is the tool-list
|
|
// counterpart of App.OpenAPISpec(): one registry, read directly.
|
|
func TestMCPTools_AreTheRegistryProjected(t *testing.T) {
|
|
// What cmd/zipdoc emits for a documented handler. The doc COMMENT is the
|
|
// description — a model choosing a tool reads the same prose a human reading
|
|
// the spec does.
|
|
zip.Describe("POST /v1/market/quote", zip.Doc{
|
|
Description: "quote returns the current ask for a symbol. It reads the venue's book,\nnot the last trade: the last trade says what someone paid and only the book\nsays what you can pay now.",
|
|
Fields: map[string]string{
|
|
"quoteIn.symbol": "Symbol is the venue's ticker, not the ISIN.",
|
|
"quoteIn.venue": "Venue narrows the quote to one book; empty takes the best.",
|
|
},
|
|
})
|
|
|
|
app := zip.New(zip.Config{AppName: "market", DisableStartupMessage: true})
|
|
// Documented the canonical way: a doc comment, no WithSummary.
|
|
zip.Post(app, "/v1/market/quote", func(_ context.Context, _ *quoteIn) (*quoteOut, error) {
|
|
return "eOut{Cents: 1200}, nil
|
|
}, zip.WithOperationID("market_quote"))
|
|
// Undocumented: WithSummary still names it.
|
|
zip.Post(app, "/v1/market/fill", func(_ context.Context, _ *fillIn) (*fillOut, error) {
|
|
return &fillOut{Filled: true}, nil
|
|
}, zip.WithOperationID("market_fill"), zip.WithSummary("Fill a resting order"))
|
|
|
|
tools := app.MCPTools()
|
|
if len(tools) != 2 {
|
|
t.Fatalf("want one tool per typed op (2), got %d", len(tools))
|
|
}
|
|
|
|
byName := map[string]map[string]any{}
|
|
for _, tool := range tools {
|
|
byName[tool["name"].(string)] = tool
|
|
}
|
|
|
|
// --- names are the ops' operation ids -------------------------------------
|
|
quote, ok := byName["market_quote"]
|
|
if !ok {
|
|
t.Fatalf("tool name must be the op's operationId; got %v", byName)
|
|
}
|
|
fill, ok := byName["market_fill"]
|
|
if !ok {
|
|
t.Fatalf("missing market_fill; got %v", byName)
|
|
}
|
|
|
|
// --- descriptions are the doc-comment prose zipdoc lifted -----------------
|
|
if got := quote["description"].(string); got != "quote returns the current ask for a symbol. It reads the venue's book,\nnot the last trade: the last trade says what someone paid and only the book\nsays what you can pay now." {
|
|
t.Fatalf("description is not the doc comment: %q", got)
|
|
}
|
|
if got := fill["description"].(string); got != "Fill a resting order" {
|
|
t.Fatalf("WithSummary fallback lost: %q", got)
|
|
}
|
|
|
|
// --- inputSchema is In's JSON Schema, prose and all -----------------------
|
|
qs := quote["inputSchema"].(map[string]any)
|
|
if qs["type"] != "object" {
|
|
t.Fatalf("inputSchema is not an object schema: %v", qs)
|
|
}
|
|
qprops := qs["properties"].(map[string]any)
|
|
if len(qprops) != 2 || qprops["symbol"] == nil || qprops["venue"] == nil {
|
|
t.Fatalf("inputSchema must carry every In field: %v", qprops)
|
|
}
|
|
if got := qprops["symbol"].(map[string]any)["description"]; got != "Symbol is the venue's ticker, not the ISIN." {
|
|
t.Fatalf("field prose did not reach the schema: %v", got)
|
|
}
|
|
if req, _ := qs["required"].([]string); len(req) != 1 || req[0] != "symbol" {
|
|
t.Fatalf(`validate:"required" must reach the schema: %v`, qs["required"])
|
|
}
|
|
|
|
// The second op's schema is its OWN In — the projection is per-op, not a
|
|
// shared blob.
|
|
fprops := fill["inputSchema"].(map[string]any)["properties"].(map[string]any)
|
|
if fprops["order_id"] == nil || fprops["qty"] == nil || fprops["symbol"] != nil {
|
|
t.Fatalf("market_fill's schema is not fillIn: %v", fprops)
|
|
}
|
|
if got := fprops["qty"].(map[string]any)["type"]; got != "integer" {
|
|
t.Fatalf("Go int must project as integer, got %v", got)
|
|
}
|
|
|
|
// --- the tool list and the OpenAPI document agree, op for op --------------
|
|
// Both read the same registry, so a disagreement here means a projection
|
|
// started deriving its own truth.
|
|
spec := app.OpenAPISpec()
|
|
paths := spec["paths"].(map[string]map[string]any)
|
|
for path, name := range map[string]string{
|
|
"/v1/market/quote": "market_quote",
|
|
"/v1/market/fill": "market_fill",
|
|
} {
|
|
post := paths[path]["post"].(map[string]any)
|
|
if post["operationId"] != name {
|
|
t.Fatalf("%s: spec operationId %v, tool name %q — one op, two names",
|
|
path, post["operationId"], name)
|
|
}
|
|
if post["description"] != nil && post["description"] != byName[name]["description"] {
|
|
t.Fatalf("%s: spec and tool describe the same op differently:\n spec=%q\n tool=%q",
|
|
path, post["description"], byName[name]["description"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMCPTools_EveryToolIsCallableOverTheCallPlane ties the two by-name
|
|
// surfaces together: every tool the MCP projection advertises is invocable by
|
|
// that same name over the op-call plane, because both resolve through the one
|
|
// registry lookup. A name that lists but cannot be called would mean the two
|
|
// projections had drifted apart.
|
|
func TestMCPTools_EveryToolIsCallableOverTheCallPlane(t *testing.T) {
|
|
app := flagsApp(t)
|
|
sock := serveUDS(t, app)
|
|
|
|
c, err := zip.Dial(sock)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
|
|
tools := app.MCPTools()
|
|
if len(tools) != 2 {
|
|
t.Fatalf("want 2 tools, got %d", len(tools))
|
|
}
|
|
for _, tool := range tools {
|
|
name := tool["name"].(string)
|
|
// A 404 here means the name the tool list advertises is not the name the
|
|
// call plane resolves. Any other outcome (including a validation error)
|
|
// proves the op was found.
|
|
_, err := zip.Call[boolIn, boolOut](context.Background(), c, name, &boolIn{Flag: "beta"})
|
|
var he *zip.HTTPError
|
|
if err != nil && asHTTPError(err, &he) && he.Status == 404 {
|
|
t.Fatalf("tool %q lists but does not resolve on the call plane", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func asHTTPError(err error, dst **zip.HTTPError) bool {
|
|
he, ok := err.(*zip.HTTPError)
|
|
if ok {
|
|
*dst = he
|
|
}
|
|
return ok
|
|
}
|