Files
zip/mcp_zap_test.go
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

295 lines
9.0 KiB
Go

package zip
import (
"context"
"encoding/json"
"io"
"net"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
zapmcp "github.com/zap-proto/mcp"
)
// The point of the whole inversion, stated as a test: an agent reaches this
// app's tools over ZAP with NO HTTP LISTENER RUNNING — no Listen, no fiber
// router, no request, no status code.
//
// Before, [App.MCP] was a fiber handler (mcpCall(fc fiber.Ctx, …)), so a
// tools/call could not exist until an HTTP request did. That put the transport
// UNDERNEATH the protocol: MCP was something HTTP carried rather than something
// ZAP speaks. The assertions below are deliberately about what is NOT running —
// a test that only checked the answers would pass just as happily with an HTTP
// server quietly behind it.
// mcpApp is an app with two typed ops and nothing else.
func mcpApp(t *testing.T) *App {
t.Helper()
app := quiet("tools")
app.Get("/v1/answer", func(ctx context.Context, in *struct {
Q string `json:"q"`
}) (*struct {
A string `json:"a"`
}, error) {
return &struct {
A string `json:"a"`
}{A: "42:" + in.Q}, nil
}, WithOperationID("answer"))
app.Post("/v1/refuse", func(ctx context.Context, in *struct{}) (*struct{}, error) {
return nil, ErrForbidden("not for you")
}, WithOperationID("refuse"))
return app
}
// zapDoor serves app.MCP over ZAP on a unix socket and returns a client. It
// never calls Listen, so nothing in this path builds a router or binds an HTTP
// port.
func zapDoor(t *testing.T, app *App) *zapmcp.Transport {
t.Helper()
sock := filepath.Join(sockDir(t), "mcp.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &zapmcp.Server{Handler: app.MCP}
done := make(chan struct{})
go func() { defer close(done); _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close(); <-done })
c := zapmcp.Dial("unix", sock)
t.Cleanup(c.CloseIdleConnections)
return c
}
func TestMCP_ServesOverZapWithNoHTTPListener(t *testing.T) {
app := mcpApp(t)
c := zapDoor(t, app)
ctx := context.Background()
// ---- the app is not serving HTTP, and never was ----------------------
if app.prepared.Load() {
t.Fatal("the fiber router was built — this app was supposed to serve only over ZAP")
}
app.srvMu.Lock()
servers := len(app.servers)
app.srvMu.Unlock()
if servers != 0 {
t.Fatalf("%d transport listener(s) running; want 0", servers)
}
if n := app.listening.Load(); n != 0 {
t.Fatalf("listening count = %d; want 0", n)
}
// ---- and yet it is a complete MCP server -----------------------------
out, err := c.Call(ctx, "initialize", nil)
if err != nil {
t.Fatalf("initialize: %v", err)
}
var hello struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo struct {
Name string `json:"name"`
} `json:"serverInfo"`
}
if err := json.Unmarshal(out, &hello); err != nil {
t.Fatalf("initialize result: %v (%s)", err, out)
}
if hello.ProtocolVersion != mcpProtocolVersion || hello.ServerInfo.Name != "tools" {
t.Fatalf("initialize = %s", out)
}
// tools/list must carry the app's REAL ops. An empty array here is the
// failure mode that looks like success: the door answers, and says nothing.
if out, err = c.Call(ctx, "tools/list", nil); err != nil {
t.Fatalf("tools/list: %v", err)
}
var listed struct {
Tools []struct {
Name string `json:"name"`
InputSchema map[string]any `json:"inputSchema"`
} `json:"tools"`
}
if err := json.Unmarshal(out, &listed); err != nil {
t.Fatalf("tools/list result: %v (%s)", err, out)
}
got := map[string]bool{}
for _, tool := range listed.Tools {
got[tool.Name] = true
if tool.InputSchema == nil {
t.Errorf("tool %q has no inputSchema", tool.Name)
}
}
if !got["answer"] || !got["refuse"] {
t.Fatalf("tools/list = %s, want both declared ops", out)
}
// tools/call runs the SAME handler core a REST request would.
if out, err = c.Call(ctx, "tools/call",
json.RawMessage(`{"name":"answer","arguments":{"q":"life"}}`)); err != nil {
t.Fatalf("tools/call: %v", err)
}
if text := contentText(t, out); text != `{"a":"42:life"}` {
t.Fatalf("tools/call content = %q", text)
}
// A handler refusal is isError content, not a transport error, per the spec.
if out, err = c.Call(ctx, "tools/call", json.RawMessage(`{"name":"refuse","arguments":{}}`)); err != nil {
t.Fatalf("a refused tool answered a transport error: %v", err)
}
if !strings.Contains(string(out), `"isError":true`) || !strings.Contains(string(out), "not for you") {
t.Fatalf("refusal = %s, want isError content carrying the handler's message", out)
}
// An unknown tool is a JSON-RPC refusal the agent can read.
if _, err = c.Call(ctx, "tools/call", json.RawMessage(`{"name":"nope","arguments":{}}`)); err == nil {
t.Fatal("an unknown tool succeeded")
} else {
var e *zapmcp.Error
if !errorAs(err, &e) || e.Code != zapmcp.CodeParams {
t.Fatalf("unknown tool = %v, want a -32602", err)
}
}
// Still nothing serving HTTP after all of that.
if app.prepared.Load() {
t.Fatal("answering MCP built the fiber router")
}
}
// The caller a typed op sees over ZAP is the one the CONTEXT carries, because a
// frame has no headers to read one out of. This is what makes org scoping work
// on the ZAP door at all.
func TestMCP_ZapCallerComesFromTheContext(t *testing.T) {
seen := make(chan Caller, 1)
app := quiet("tools")
app.Get("/v1/whoami", func(ctx context.Context, in *struct{}) (*struct {
Org string `json:"org"`
}, error) {
seen <- CallerOf(ctx)
return &struct {
Org string `json:"org"`
}{Org: CallerOf(ctx).Org}, nil
}, WithOperationID("whoami"))
sock := filepath.Join(sockDir(t), "mcp.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen: %v", err)
}
// The server states the caller for every frame on the connection — which is
// where a deployment reads the peer credential and decides who this is.
srv := &zapmcp.Server{Handler: func(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
return app.MCP(WithCaller(ctx, Caller{Org: "acme", User: "z"}), f)
}}
done := make(chan struct{})
go func() { defer close(done); _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close(); <-done })
c := zapmcp.Dial("unix", sock)
t.Cleanup(c.CloseIdleConnections)
out, err := c.Call(context.Background(), "tools/call",
json.RawMessage(`{"name":"whoami","arguments":{}}`))
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if text := contentText(t, out); text != `{"org":"acme"}` {
t.Fatalf("content = %q, want the ctx's org", text)
}
if who := <-seen; who.Org != "acme" || who.User != "z" {
t.Fatalf("handler saw %+v", who)
}
}
// The two doors are one door. Whatever the HTTP route answers, the ZAP socket
// answers identically — that is what "HTTP is an adapter" has to mean, and the
// only way to be sure is to ask both and compare the bytes.
func TestMCP_BothDoorsAnswerTheSame(t *testing.T) {
app := mcpApp(t)
c := zapDoor(t, app)
over := func(method, params string) string {
t.Helper()
var raw json.RawMessage
if params != "" {
raw = json.RawMessage(params)
}
out, err := c.Call(context.Background(), method, raw)
if err != nil {
t.Fatalf("zap %s: %v", method, err)
}
return string(out)
}
// The HTTP door, on a SECOND app declared identically, so neither door can
// be answering out of state the other one warmed.
edge := mcpApp(t)
body := func(method, params string) string {
t.Helper()
msg := `{"jsonrpc":"2.0","id":1,"method":"` + method + `"`
if params != "" {
msg += `,"params":` + params
}
msg += `}`
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(msg))
req.Header.Set("Content-Type", mimeJSON)
resp, err := edge.Test(req)
if err != nil {
t.Fatalf("http %s: %v", method, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("http %s: %v", method, err)
}
var env struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("http %s: %v (%s)", method, err, raw)
}
return string(env.Result)
}
for _, tc := range []struct{ method, params string }{
{"tools/list", ""},
{"ping", ""},
{"tools/call", `{"name":"answer","arguments":{"q":"x"}}`},
{"tools/call", `{"name":"refuse","arguments":{}}`},
} {
z, h := over(tc.method, tc.params), body(tc.method, tc.params)
if z != h {
t.Errorf("%s: the doors disagree\n zap: %s\nhttp: %s", tc.method, z, h)
}
}
}
// errorAs is errors.As, named here so this file does not import errors for one
// call and shadow the package's own vocabulary.
func errorAs(err error, target **zapmcp.Error) bool {
e, ok := err.(*zapmcp.Error)
if ok {
*target = e
}
return ok
}
// contentText lifts the single text content out of an MCP tool result.
func contentText(t *testing.T, result []byte) string {
t.Helper()
var r struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(result, &r); err != nil {
t.Fatalf("tool result: %v (%s)", err, result)
}
if len(r.Content) != 1 {
t.Fatalf("tool result carries %d content items: %s", len(r.Content), result)
}
return r.Content[0].Text
}