mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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]>
200 lines
7.5 KiB
Go
200 lines
7.5 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
type greetIn struct {
|
|
Name string `json:"name" validate:"required"`
|
|
}
|
|
type greetOut struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// TestMCP_FreeToolSurface proves MCP is a FREE third projection of the typed-op
|
|
// registry: a Get/Post[In,Out] handler shows up as an MCP tool (tools/list) with
|
|
// the same JSON Schema OpenAPI uses, and tools/call runs the exact same fn and
|
|
// returns its output — no per-tool wiring. Served over the app's transports.
|
|
func TestMCP_FreeToolSurface(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "greeter", DisableStartupMessage: true})
|
|
app.Post("/v1/greet", func(_ context.Context, in *greetIn) (*greetOut, error) {
|
|
return &greetOut{Message: "hello " + in.Name}, nil
|
|
}, zip.WithOperationID("greet"), zip.WithSummary("Greet someone by name"))
|
|
|
|
httpAddr := freeAddr(t)
|
|
go func() { _ = app.Listen("http://" + httpAddr) }()
|
|
defer func() { _ = app.Shutdown() }()
|
|
waitHTTP(t, "http://"+httpAddr+"/.well-known/openapi.json")
|
|
|
|
base := "http://" + httpAddr + "/mcp"
|
|
|
|
// initialize
|
|
init := rpc(t, base, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`)
|
|
if init["result"].(map[string]any)["protocolVersion"] == nil {
|
|
t.Fatalf("initialize missing protocolVersion: %v", init)
|
|
}
|
|
|
|
// tools/list — the typed op must appear as a tool with a real inputSchema.
|
|
list := rpc(t, base, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
|
|
tools := list["result"].(map[string]any)["tools"].([]any)
|
|
if len(tools) != 1 {
|
|
t.Fatalf("want 1 tool, got %d: %v", len(tools), tools)
|
|
}
|
|
tool := tools[0].(map[string]any)
|
|
if tool["name"] != "greet" || tool["description"] != "Greet someone by name" {
|
|
t.Fatalf("tool metadata wrong: %v", tool)
|
|
}
|
|
schema := tool["inputSchema"].(map[string]any)
|
|
props, _ := schema["properties"].(map[string]any)
|
|
if props["name"] == nil {
|
|
t.Fatalf("inputSchema missing 'name' property (schemaOf projection): %v", schema)
|
|
}
|
|
|
|
// tools/call — runs the SAME fn and returns its output as text content.
|
|
call := rpc(t, base, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{"name":"ada"}}}`)
|
|
content := call["result"].(map[string]any)["content"].([]any)
|
|
text := content[0].(map[string]any)["text"].(string)
|
|
if !strings.Contains(text, `"message":"hello ada"`) {
|
|
t.Fatalf("tools/call output wrong: %q", text)
|
|
}
|
|
|
|
// validation flows through: a missing required arg is an isError result.
|
|
bad := rpc(t, base, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"greet","arguments":{}}}`)
|
|
if bad["result"].(map[string]any)["isError"] != true {
|
|
t.Fatalf("missing required 'name' should be isError: %v", bad)
|
|
}
|
|
}
|
|
|
|
type stockIn struct {
|
|
SKU string `json:"sku" validate:"required"`
|
|
Depot string `json:"depot"`
|
|
}
|
|
type stockOut struct {
|
|
Units int `json:"units"`
|
|
}
|
|
|
|
// TestMCP_ToolsCarryZipdocProse proves the tool list reads the SAME cmd/zipdoc
|
|
// extraction the OpenAPI doc and the CLI help read. Before this, mcpTools took
|
|
// only WithSummary, so an op documented the canonical way — a doc comment, no
|
|
// WithSummary — projected into a tool with an empty description and a schema
|
|
// whose fields said nothing, which is the one thing a model needs to choose it.
|
|
func TestMCP_ToolsCarryZipdocProse(t *testing.T) {
|
|
// What cmd/zipdoc emits into an init() for a documented handler.
|
|
zip.Describe("POST /v1/stock", zip.Doc{
|
|
Description: "stock reports on-hand units for a SKU. It reads the depot ledger, not the\ncatalog: the catalog says what may be sold and only the ledger says what is\nthere.",
|
|
Fields: map[string]string{
|
|
"stockIn.sku": "SKU is the catalog identifier, not the supplier's part number.",
|
|
"stockIn.depot": "Depot narrows the count to one warehouse; empty counts them all.",
|
|
},
|
|
})
|
|
|
|
app := zip.New(zip.Config{AppName: "wms", DisableStartupMessage: true})
|
|
// No WithSummary — the doc comment IS the description.
|
|
app.Post("/v1/stock", func(_ context.Context, _ *stockIn) (*stockOut, error) {
|
|
return &stockOut{Units: 3}, nil
|
|
}, zip.WithOperationID("stock"))
|
|
|
|
httpAddr := freeAddr(t)
|
|
go func() { _ = app.Listen("http://" + httpAddr) }()
|
|
defer func() { _ = app.Shutdown() }()
|
|
waitHTTP(t, "http://"+httpAddr+"/.well-known/openapi.json")
|
|
|
|
list := rpc(t, "http://"+httpAddr+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
tools := list["result"].(map[string]any)["tools"].([]any)
|
|
if len(tools) != 1 {
|
|
t.Fatalf("want 1 tool, got %d: %v", len(tools), tools)
|
|
}
|
|
tool := tools[0].(map[string]any)
|
|
|
|
if !strings.Contains(tool["description"].(string), "reads the depot ledger") {
|
|
t.Fatalf("tool description did not come from the zipdoc extraction: %q", tool["description"])
|
|
}
|
|
|
|
// Field prose reaches the schema too — same "Type.field" key the CLI reads.
|
|
props := tool["inputSchema"].(map[string]any)["properties"].(map[string]any)
|
|
sku, _ := props["sku"].(map[string]any)
|
|
if sku == nil || !strings.Contains(sku["description"].(string), "supplier's part number") {
|
|
t.Fatalf("inputSchema field lost its doc comment: %v", props)
|
|
}
|
|
|
|
// The OpenAPI projection of the same op agrees — one comment, two surfaces.
|
|
spec := getJSON(t, "http://"+httpAddr+"/.well-known/openapi.json")
|
|
post := spec["paths"].(map[string]any)["/v1/stock"].(map[string]any)["post"].(map[string]any)
|
|
if post["description"] != tool["description"] {
|
|
t.Fatalf("spec and tool describe the same op differently:\n spec=%q\n tool=%q", post["description"], tool["description"])
|
|
}
|
|
}
|
|
|
|
// TestMCP_SummaryStillDescribesUndocumentedOp keeps the fallback honest: an op
|
|
// whose package cmd/zipdoc never ran over still names itself from WithSummary.
|
|
func TestMCP_SummaryStillDescribesUndocumentedOp(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "bare", DisableStartupMessage: true})
|
|
app.Post("/v1/ping", func(_ context.Context, _ *greetIn) (*greetOut, error) {
|
|
return &greetOut{Message: "pong"}, nil
|
|
}, zip.WithOperationID("ping"), zip.WithSummary("Ping the service"))
|
|
|
|
httpAddr := freeAddr(t)
|
|
go func() { _ = app.Listen("http://" + httpAddr) }()
|
|
defer func() { _ = app.Shutdown() }()
|
|
waitHTTP(t, "http://"+httpAddr+"/.well-known/openapi.json")
|
|
|
|
list := rpc(t, "http://"+httpAddr+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
tool := list["result"].(map[string]any)["tools"].([]any)[0].(map[string]any)
|
|
if tool["description"] != "Ping the service" {
|
|
t.Fatalf("WithSummary fallback lost: %v", tool)
|
|
}
|
|
}
|
|
|
|
func getJSON(t *testing.T, url string) map[string]any {
|
|
t.Helper()
|
|
code, body, err := fasthttp.Get(nil, url)
|
|
if err != nil || code != 200 {
|
|
t.Fatalf("get %s: code=%d err=%v", url, code, err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
t.Fatalf("decode %s: %v", url, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func rpc(t *testing.T, url, body string) map[string]any {
|
|
t.Helper()
|
|
req := fasthttp.AcquireRequest()
|
|
resp := fasthttp.AcquireResponse()
|
|
defer fasthttp.ReleaseRequest(req)
|
|
defer fasthttp.ReleaseResponse(resp)
|
|
req.SetRequestURI(url)
|
|
req.Header.SetMethod(fasthttp.MethodPost)
|
|
req.Header.SetContentType("application/json")
|
|
req.SetBodyString(body)
|
|
if err := fasthttp.Do(req, resp); err != nil {
|
|
t.Fatalf("mcp rpc %q: %v", body, err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp.Body(), &out); err != nil {
|
|
t.Fatalf("mcp rpc decode: %v (body=%s)", err, resp.Body())
|
|
}
|
|
return out
|
|
}
|
|
|
|
func waitHTTP(t *testing.T, url string) {
|
|
t.Helper()
|
|
for i := 0; i < 50; i++ {
|
|
code, _, err := fasthttp.Get(nil, url)
|
|
if err == nil && code == 200 {
|
|
return
|
|
}
|
|
time.Sleep(40 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s never became reachable", url)
|
|
}
|