Files
zeekay 5085333c13 Keep every existing op's wire and close the new kinds' edges
A JSON answer keeps fiber's application/json; charset=utf-8 unless the
op names another media, and every declared status carries the Out's
schema again, a union's alternative filed under the status it states.
A value of the op's own type stating status zero is refused as before;
only Body and Redirect read zero as the op's own. A header field is a
CLI flag under its field's name as it was, and a 3xx with a body is an
answer the remote CLI prints.

form: tags bind a form only when the op consumes a form media type, so
another binder's tags no longer move an op off JSON or refuse a GET.
A cookie: field is set by the request's cookie and nothing else, and no
body schema lists it, so a cross-site body cannot fill it.

Sse.Send takes a ctx that ends with the stream: a tool call collects
for at most toolWait and toolBound bytes, then cancels the producer,
and a stuck producer cannot hold the call. A panicking Reader ends its
answer, not the process. A socket op refuses a request that cannot
upgrade before its handler runs, over REST and the call plane, and a
nil CheckOrigin is same-origin, refused 403. The call plane carries a
union's JSON alternative; Sse and Socket fields that cannot cross say
so with zap:"-". MCP bytes are bounded and untyped bytes take the op's
media.
2026-10-09 09:42:01 -07:00

923 lines
37 KiB
Go

package zip
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
zapmcp "github.com/zap-proto/mcp"
"github.com/zap-proto/zip/internal/jsonenc"
)
// MCP — the THIRD projection. The same typed-op registry (a.registry) that produces
// the REST routes and the OpenAPI doc also produces a Model Context Protocol
// tool surface, for FREE: every typed handler (Get/Post[In,Out]) becomes an MCP
// tool whose inputSchema is schemaOf(In) and whose call runs the exact same fn
// (op.invoke). One value (the op), three projections (REST · OpenAPI · MCP).
//
// # ZAP is underneath, not beside
//
// MCP is a protocol on the ZAP wire, the sibling of zap-proto/http — so the door
// here takes a [zapmcp.Frame] and answers one, and knows nothing about any
// transport. [App.MCP] IS a zapmcp.Handler, which means an agent reaches these
// tools over ZAP with no HTTP listener running at all:
//
// srv := &zapmcp.Server{Network: "unix", Addr: sock, Handler: app.MCP}
//
// HTTP is then an ADAPTER over that door — the /mcp route in installMCP reads a
// JSON-RPC body into a frame, calls App.MCP, and writes the answer back — rather
// than the thing the door is built out of.
//
// It used to be the other way round: the door's own signature took a fiber.Ctx
// and read the caller off it, so a tools/call could not happen unless an HTTP
// request already had. "ZAP-native MCP" was true only in the sense that ZAP
// carried the HTTP request that carried the tool call. Now the frame is the
// unit and the wire is a choice. See TestMCP_ServesOverZapWithNoHTTPListener,
// and TestFiberIsNotInTheDispatchPath for the rule that keeps it that way.
//
// The JSON-RPC 2.0 envelope is zapmcp.Frame's own JSON projection, so the bytes
// an agent reads over HTTP and the bytes it reads over ZAP describe one value
// and cannot drift.
// MCPConfig configures the auto-derived MCP surface.
type MCPConfig struct {
// Disabled suppresses the /mcp route (MCP is on by default — it's free).
Disabled bool
// Path overrides the HTTP mount path (default "/mcp").
Path string
// Addr is where this app serves MCP over ZAP, natively — no HTTP listener,
// no request, no status code, just frames. Empty binds nothing, exactly as
// [Config.OpsAddr] does: an address is deployment configuration and a
// deployment states it.
//
// zip.MCPConfig{Addr: zip.SocketPath("flags-mcp")}
//
// The door itself is [App.MCP], so a deployment that wants it somewhere this
// does not reach serves that handler with a zapmcp.Server of its own. This is
// the convenience, not a second mechanism.
Addr string
// Name is the server name reported to MCP clients (default AppName, else "zip").
Name string
// Source is the door's PER-CALLER half: the tools that exist because of who
// is asking, which no build-time projection can hold. Nil — the default —
// leaves the door exactly the typed-op projection, answered as bytes.
Source Source
// Identify says WHO is on a ZAP-native connection, and is the other half of
// [Source] on that wire.
//
// Over HTTP the gateway's assertion arrives in headers and the route reads it.
// On [Addr] a frame is a frame: there is nothing to read, so nothing states
// the caller unless a deployment does — and [App.list] answers the build-time
// bytes to a caller it cannot name. A door declaring both and no Identify is
// therefore refused at [App.Listen] rather than served half: the tools that
// exist because of who is asking are exactly the ones such a door drops, and
// it drops them while answering, logging and probing healthy.
//
// The context carries the connection the frame arrived on ([zapmcp.Conn]),
// which is what a deployment reads to decide: the kernel's peer credential
// over a unix socket — the one thing about a caller the caller does not get
// to state — the socket it came in on, its key material. zip does not choose
// among those, for the same reason zapmcp hands over the connection rather
// than a digest of it.
//
// MCP: zip.MCPConfig{Addr: sock, Source: rows, Identify: func(ctx context.Context) zip.Caller {
// return zip.Caller{Org: orgOfUID(zapmcp.Conn(ctx))}
// }}
//
// The zero [Caller] means anonymous, which leaves the door exactly the
// build-time projection — the honest answer for a peer this deployment
// cannot attest. It is not consulted on the HTTP route, where the assertion
// is already on the request and a second authority is one too many.
Identify func(ctx context.Context) Caller
}
// Source is the half of an MCP door that depends on the CALLER.
//
// A typed op is a value known at build time, so zip projects it into a tool once
// and serves the array as bytes. A tenant's own capabilities are rows: they exist
// because of who is asking, so they cannot be projected and cannot be cached
// across callers. Both are tools on ONE door — this is how the second kind gets
// there, without a second registry in front of it.
//
// Tools is called on every tools/list that carries a caller, so an implementation
// answers from data it already has and never fans out. Call runs a name the
// build-time catalogue did not claim; returning an error is reported to the model
// as MCP isError content, exactly like a typed op's error, so a refusal is
// something it can read and react to.
//
// The context is the one the request is being served on — the same value a typed
// op receives — so an implementation reads its caller from there and never from
// the arguments, which the caller wrote.
type Source interface {
// Tools are the caller's own tool descriptors: {name, description, inputSchema},
// the same shape [App.MCPTools] projects.
Tools(ctx context.Context) []map[string]any
// Call runs one of them with the raw JSON arguments object and returns its
// JSON-encodable result.
Call(ctx context.Context, name string, args json.RawMessage) (any, error)
}
// mimeJSON is the boundary content type, spelled once. HIP-0106: JSON at the
// edge, ZAP between services — so this is what an answer a browser or an agent
// reads carries, and it is stated here rather than reached for through the
// router, which is not in the path of everything that writes one.
const mimeJSON = "application/json"
// mcpProtocolVersion is the MCP spec revision zip implements.
//
// 2026-07-28 made MCP STATELESS, and zip already was: there is no
// Mcp-Session-Id anywhere in this file, nothing is remembered between requests,
// and tools/list answers without a preceding initialize. So this door met the
// new revision's central requirement before the revision existed, and went on
// announcing the old one — which is the version a client negotiates against.
//
// Announcing it is therefore a statement about what is implemented, not an
// upgrade: stateless transport, no mandatory handshake, server/discover
// answered, and initialize kept for older clients.
//
// What this revision DEPRECATES, zip never had — roots, sampling, logging, and
// the HTTP+SSE transport are absent here, so nothing needs a migration window.
const mcpProtocolVersion = "2026-07-28"
// mcpRevisions are the revisions this door answers in. The two before
// mcpProtocolVersion define the same Streamable HTTP transport and the same tool
// methods, and neither has JSON-RPC batches, so a client that asks for one is
// served it as written.
var mcpRevisions = map[string]bool{"2025-06-18": true, "2025-11-25": true, mcpProtocolVersion: true}
// negotiate is the revision an initialize is answered in, by the MCP lifecycle's
// version negotiation: the one the client asked for when this door speaks it,
// else the newest.
func negotiate(params []byte) string {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(params, &p) == nil && mcpRevisions[p.ProtocolVersion] {
return p.ProtocolVersion
}
return mcpProtocolVersion
}
// defaultMCPPath is where an app serves its own MCP door unless MCPConfig moves
// it, and therefore where a host forwards a composed tools/call.
const defaultMCPPath = "/mcp"
func (a *App) mcpPath() string {
if a.cfg.MCP.Path != "" {
return a.cfg.MCP.Path
}
return defaultMCPPath
}
func (a *App) mcpName() string {
switch {
case a.cfg.MCP.Name != "":
return a.cfg.MCP.Name
case a.cfg.AppName != "":
return a.cfg.AppName
default:
return "zip"
}
}
// installMCP mounts the JSON-RPC 2.0 MCP endpoint when there is anything to
// expose — this app's own typed ops, or the catalogues of the plugins it
// composed. Called from prepare() alongside installOpenAPIRoutes.
//
// The composed list is rendered ONCE, here, and served as bytes: tools/list is
// the method an MCP client calls constantly, and a host composing a lazy fleet
// must answer it without touching a single child. A pluginTools entry is a
// build-time catalogue, so the answer is a memcpy and the process count is zero.
func (a *App) installMCP() {
if a.cfg.MCP.Disabled || (len(a.Registry()) == 0 && len(a.tools()) == 0 && !a.hasCaller()) {
return
}
a.mcpOnce.Do(a.renderTools)
// HTTP is an ADAPTER over the door, not a peer of it: read the JSON-RPC body
// into a frame, answer it, write the answer back. Everything HTTP knows about
// this surface is in these nine lines — which is the whole point, and the
// reason the same door needs no second implementation to be served over ZAP.
a.control(fiber.MethodPost, a.mcpPath(), func(fc fiber.Ctx) error {
var f zapmcp.Frame
if err := json.Unmarshal(fc.Body(), &f); err != nil {
return mcpSend(fc, &zapmcp.Frame{Kind: zapmcp.Response,
Err: &zapmcp.Error{Code: zapmcp.CodeParse, Message: "parse error"}})
}
ans := a.MCP(callerContext(fc), &f)
if ans == nil {
return fc.SendStatus(fiber.StatusAccepted) // a notification
}
return mcpSend(fc, ans)
})
a.logger.Info("zip mcp", "path", a.mcpPath(), "ops", len(a.Registry()),
"plugin tools", len(a.tools()), "per-caller", a.hasCaller())
}
// mcpSend writes one frame as the JSON-RPC message an HTTP client reads. The
// rendering is [zapmcp.Frame]'s own, so the bytes an agent sees over HTTP and
// the bytes it sees over ZAP describe the same value and cannot drift.
func mcpSend(fc fiber.Ctx, f *zapmcp.Frame) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
fc.Set(fiber.HeaderContentType, mimeJSON)
return fc.Send(b)
}
// hasCaller reports that this door has a per-caller half at all — a Source of its
// own, or an OPEN plugin to ask. When it has none, tools/list stays the memcpy it
// was and nothing below this line runs.
func (a *App) hasCaller() bool {
if a.cfg.MCP.Source != nil {
return true
}
return a.openPlugin() != nil
}
// renderTools re-renders the served list from the current ops + catalogues. Called
// by installMCP, and again by any load that lands AFTER it — a host that composes a
// plugin at run time must not serve a list frozen at boot, and the alternative
// (rendering per request) would give back the memcpy that makes tools/list free.
func (a *App) renderTools() {
list, names, refused := a.composeTools()
a.mcpList.Store(&list)
a.mcpNames.Store(&names)
a.mcpRefused.Store(&refused)
}
// mcpTool is one tool descriptor with its name lifted out, so the composed list
// can be sorted without re-parsing and a plugin's catalogue can be carried
// verbatim — the bytes its own MCPTools() projected, never a re-encoding.
type mcpTool struct {
name string
raw json.RawMessage
}
// composeTools renders the whole build-time tool array: this app's own ops plus
// every plugin catalogue, sorted by name. One order, so a client's list is stable
// and a committed catalogue does not churn on registration order.
//
// It returns the rendered bytes AND the set of names in them, because the
// per-caller half has to know what the build-time half already claims and reading
// that back out of the bytes would re-parse hundreds of schemas per request.
func (a *App) composeTools() (json.RawMessage, map[string]bool, map[string]string) {
own := a.Registry()
pluginTools := a.tools()
all := make([]mcpTool, 0, len(own)+len(pluginTools))
// The undeclared routes, read from the plan.
//
// NOT from Declaration(): that calls prepare(), and prepare is what installs
// this door — so asking it here is a cycle. The fact lives on the route
// entry either way, which is what makes it survive composition, so this
// reads the same entries Declaration reads and stops one step earlier.
hidden := map[string]bool{}
for _, o := range a.plan() {
if r, ok := o.route(); ok && r.undeclared && r.method != "" {
hidden[r.method+" "+o.abs(r.path)] = true
}
}
// An op kept out of the contract is kept out of the tool list.
//
// [Undeclared] promises exactly this — "and so in none of the projections
// built from it: the OpenAPI document, the MCP tool list, the CLI commands,
// the by-name call plane" — and the miss costs most here: a document nobody
// reads lists a dead address, but a tool list is what an agent CALLS.
skip := map[string]bool{}
for _, op := range own {
if hidden[op.Method+" "+op.Path] {
skip[opName(op)] = true
}
}
// The tools are [ProjectMCP] over this app's manifest: one projection,
// whether it is served here, embedded by a host as a catalogue, or read
// in-process through MCPTools.
m := a.Manifest()
for _, t := range ProjectMCP(m) {
name := t["name"].(string)
if skip[name] {
continue
}
b, err := json.Marshal(t)
if err != nil {
a.logger.Warn("zip mcp: op has no renderable schema", "op", name, "err", err)
continue
}
all = append(all, mcpTool{name: name, raw: b})
}
refused := RefusedMCP(m)
for name := range skip {
delete(refused, name)
}
all = append(all, pluginTools...)
sort.Slice(all, func(i, j int) bool { return all[i].name < all[j].name })
raws := make([]json.RawMessage, len(all))
names := make(map[string]bool, len(all))
for i, t := range all {
raws[i] = t.raw
names[t.name] = true
}
b, err := json.Marshal(raws)
if err != nil {
return json.RawMessage("[]"), names, refused
}
return b, names, refused
}
// installTools adds one plugin's catalogue to the composed surface, refusing a
// tool name another plugin already holds. plugMu must be held.
//
// It is the Load-time analogue of [App.claim] and of the document's unique
// operation ids: a tool NAME is dispatch, so two owners make it unroutable. A
// composition that would serve one name from two places fails at boot with both
// names, rather than silently forwarding every call to whichever loaded first.
func (a *App) installTools(p *plugin, tools []mcpTool) error {
// Refused BEFORE anything is recorded, so a rejected Load leaves the door
// exactly as it found it.
if p.spec.Open && a.open != nil {
return fmt.Errorf("plugin %q is already the open one — a name no catalogue claims has one owner, and two would make it ambiguous", a.open.name)
}
if len(tools) > 0 {
if a.toolOwners == nil {
a.toolOwners = map[string]*plugin{}
}
for i, t := range tools {
if held, dup := a.toolOwners[t.name]; dup {
for _, undo := range tools[:i] {
delete(a.toolOwners, undo.name)
}
return fmt.Errorf("tool %q is already served by plugin %q", t.name, held.name)
}
a.toolOwners[t.name] = p
}
a.pluginTools = append(a.pluginTools, tools...)
// The door is already up (this Load came after Listen): re-render, so a
// plugin composed at run time is on the list rather than invisible until a
// restart.
if a.mcpList.Load() != nil {
a.renderTools()
}
}
if p.spec.Open {
a.open = p
}
return nil
}
// parseTools reads a plugin's catalogue — the JSON array [App.App.MCPTools]
// projects, captured at build time — and lifts each tool's name out for the
// owner index. A malformed catalogue is an error at Load, not a surprise at the
// first tools/list.
func parseTools(name string, b []byte) ([]mcpTool, error) {
if len(b) == 0 {
return nil, nil
}
var raws []json.RawMessage
if err := json.Unmarshal(b, &raws); err != nil {
return nil, fmt.Errorf("plugin %q: Tools is not a JSON array of tool descriptors: %w", name, err)
}
out := make([]mcpTool, 0, len(raws))
for _, raw := range raws {
var hdr struct{ Name string }
if err := json.Unmarshal(raw, &hdr); err != nil || hdr.Name == "" {
return nil, fmt.Errorf("plugin %q: a tool descriptor has no name", name)
}
out = append(out, mcpTool{name: hdr.Name, raw: raw})
}
return out, nil
}
// MCP answers one MCP message. It is this app's tool door as a VALUE — a frame
// in, a frame out, nothing underneath it — which is the whole of why MCP can be
// served over ZAP natively:
//
// srv := &zapmcp.Server{Network: "unix", Addr: sock, Handler: app.MCP}
//
// HTTP is then an ADAPTER over this, not a peer of it: installMCP's route reads
// a JSON-RPC body into a frame, calls this, and writes the answer back. Before,
// the door's own signature took a fiber.Ctx, so an agent could not reach a tool
// without an HTTP request existing first — which put the transport underneath
// the protocol instead of the other way round.
//
// The caller is read from ctx ([CallerOf]), never from the frame: over HTTP the
// adapter binds the gateway's assertion onto the context, and over ZAP a caller
// states one with [WithCaller]. The frame's own Subject says who SIGNED it,
// which is a different question and not one this door answers.
//
// A nil answer means there is nothing to say — a notification. [zapmcp.Server]
// writes nothing; the HTTP adapter answers 202.
func (a *App) MCP(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
// The tool list is a projection of the registry, like the OpenAPI document,
// and belongs to whichever door asks for it first. Rendering it in the HTTP
// installer meant an app served ONLY over ZAP answered tools/list with an
// empty array — the door worked and had nothing to say, which is the subtlest
// possible way for an inversion to be incomplete.
a.mcpOnce.Do(a.renderTools)
switch f.Method {
// initialize and server/discover answer the SAME thing, because under a
// stateless protocol they ask the same question.
//
// 2026-07-28 removed the mandatory handshake: there is no session to open, so
// `initialize` stopped being a lifecycle step and became one more way to ask
// "what are you". `server/discover` is the name the new spec gives that
// question, and it is optional — which is exactly why it must be answered
// rather than skipped. A client that uses it to inspect a server before
// committing gets `method not found` from a server that is otherwise fully
// stateless, and reads the whole door as pre-2026.
//
// `initialize` stays for every client still speaking the older revision. It
// costs one case and keeps them working, which is the compatibility the spec's
// own migration path assumes.
case "initialize", "server/discover":
return f.Answer(mcpJSON(map[string]any{
"protocolVersion": negotiate(f.Params),
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": a.mcpName(), "version": a.cfg.OpenAPI.Version},
}))
case "tools/list":
result := map[string]any{"tools": a.list(ctx)}
if refused := a.mcpRefused.Load(); refused != nil && len(*refused) > 0 {
result["_meta"] = map[string]any{"refused": *refused}
}
return f.Answer(mcpJSON(result))
case "tools/call":
return a.tool(ctx, f)
case "ping":
return f.Answer([]byte("{}"))
}
// notifications/* carry no id and expect no result.
if f.Kind == zapmcp.Notify {
return nil
}
return f.Fail(zapmcp.CodeMethod, "method not found: "+f.Method)
}
// mcpJSON renders a result. An unrenderable one is a programming error in this
// file — every value here is a literal map — so it answers null rather than
// growing an error path no caller could act on.
func mcpJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte("null")
}
return b
}
// list answers one tools/list: the build-time array, plus the tools that exist
// only for THIS caller.
//
// The build-time half stays what it was — the pre-rendered bytes, verbatim, no
// marshal of 451 schemas and no plugin touched to produce it. A door with no
// per-caller half returns exactly those bytes, so the memcpy that makes the
// most-called MCP method free is untouched by this file.
//
// The per-caller half runs only when the call NAMES a caller, which is the
// whole of why it can be afforded: a tenant's own tools cannot be known without
// asking, and there is nothing to ask about when nobody is asking. An anonymous
// probe therefore still costs a memcpy and starts no child.
//
// The caller comes off the ctx rather than off a request header, so the answer
// is the same over ZAP as over HTTP — a door that read fc.Get(HeaderOrg) could
// only ever have a per-caller half on the transport that has headers.
func (a *App) list(ctx context.Context) json.RawMessage {
fleet := json.RawMessage("[]")
if cur := a.mcpList.Load(); cur != nil {
fleet = *cur
}
if !a.hasCaller() || CallerOf(ctx).Org == "" {
return fleet
}
mine := a.source(ctx)
if len(mine) == 0 {
return fleet
}
// Appended, not merged: the fleet's half is byte-identical to the artifact
// every projection agrees on, and re-sorting the union would re-encode it.
// Each half is sorted, so the answer is stable either way.
sort.Slice(mine, func(i, j int) bool { return mine[i].name < mine[j].name })
raws := make([]json.RawMessage, 0, len(mine))
for _, t := range mine {
raws = append(raws, t.raw)
}
tail, err := json.Marshal(raws)
if err != nil || len(fleet) < 2 || len(tail) < 2 {
return fleet
}
if string(fleet) == "[]" {
return tail
}
out := make([]byte, 0, len(fleet)+len(tail))
out = append(out, fleet[:len(fleet)-1]...) // drop the closing ]
out = append(out, ',')
out = append(out, tail[1:]...) // drop the opening [
return out
}
// source is every tool that exists because of WHO is asking: this app's own
// [Source], and every OPEN plugin's answer to the same question. A name the
// build-time catalogue already claims is dropped — one name is one dispatch,
// and the projected op is the one the whole fleet agreed on.
func (a *App) source(ctx context.Context) []mcpTool {
// fleet is READ-ONLY and shared by every call; mine is this one's own.
// Writing the caller's names into the fleet set would be two bugs at once,
// and both are silent: one tenant's tool name would be "already claimed" for
// every tenant after it, and two concurrent lists would be concurrent writes
// to one map — a runtime FATAL no recover() can catch, because tools/list is
// exactly the method that arrives in parallel.
var fleet map[string]bool
if cur := a.mcpNames.Load(); cur != nil {
fleet = *cur
}
mine := map[string]bool{}
var out []mcpTool
keep := func(name string, raw json.RawMessage) {
if name == "" || fleet[name] || mine[name] {
return
}
mine[name] = true
out = append(out, mcpTool{name: name, raw: raw})
}
if src := a.cfg.MCP.Source; src != nil {
for _, t := range src.Tools(ctx) {
name, _ := t["name"].(string)
raw, err := json.Marshal(t)
if err != nil {
a.logger.Warn("zip mcp: caller tool has no renderable schema", "tool", name, "err", err)
continue
}
keep(name, raw)
}
}
if p := a.openPlugin(); p != nil {
for _, t := range a.ask(ctx, p) {
keep(t.name, t.raw)
}
}
return out
}
// openPlugin is the plugin that declared an OPEN catalogue, or nil. At most one:
// see installTools.
func (a *App) openPlugin() *plugin {
for _, h := range a.hosts() {
h.plugMu.Lock()
p := h.open
h.plugMu.Unlock()
if p != nil {
return p
}
}
return nil
}
// ask puts a tools/list to one open plugin and lifts the tools out of its
// reply. The child answers as itself — its own registry, its own [Source], its
// own view of the caller — so the host learns the tenant's tools without holding
// the tenant's data.
//
// It builds the question rather than forwarding the one that arrived, which is
// what lets a host with no HTTP request in hand (an agent reaching it over ZAP)
// still compose an open plugin's surface. It also means the child receives the
// caller's IDENTITY and nothing else, through the one function that forwards it
// — where forwarding the inbound request verbatim handed the child every header
// the edge happened to carry.
//
// A child that fails contributes nothing. A tools/list is a question with a
// partial answer available, and blanking the fleet's whole surface because one
// plugin is down is a far worse answer than a shorter list.
func (a *App) ask(ctx context.Context, p *plugin) []mcpTool {
ans := a.relay(ctx, &zapmcp.Frame{
Kind: zapmcp.Request, ID: "1", Method: "tools/list", Params: []byte("{}"),
}, p)
if ans == nil || ans.Err != nil {
a.logger.Warn("zip mcp: open plugin did not answer tools/list", "plugin", p.name, "err", ans.Err)
return nil
}
var env struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ans.Result, &env); err != nil {
a.logger.Warn("zip mcp: open plugin sent an unreadable tools/list", "plugin", p.name, "err", err)
return nil
}
out := make([]mcpTool, 0, len(env.Tools))
for _, raw := range env.Tools {
var named struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &named); err != nil || named.Name == "" {
continue
}
out = append(out, mcpTool{name: named.Name, raw: raw})
}
return out
}
// MCPTools is the app's full MCP tool surface, projected from the typed-op
// registry — the tool-list counterpart of [App.OpenAPISpec], and the whole of
// what a plugin has to do to be an MCP server: nothing. Every typed op is one
// tool, named by its operation id, described by its doc comment, with the same
// JSON Schema the OpenAPI document carries.
//
// Serving it is already handled: the /mcp route rides every transport the app
// Listens on. Read it directly when a host wants a plugin's tools in-process —
// composing several plugins' surfaces, filtering them, or asserting them in a
// test — without a round trip.
func (a *App) MCPTools() []map[string]any { return a.mcpTools() }
// mcpTools projects every typed op into an MCP tool descriptor. The inputSchema
// is the SAME schemaOfDoc(InType) the OpenAPI doc uses, carrying the SAME prose
// cmd/zipdoc lifted from the handler — one comment, three surfaces (spec, CLI
// help, tool list). A model picking a tool from a list is doing what a human
// reading the spec does and needs the same words to do it; reading only
// WithSummary here left every zipdoc'd op with a nameless schema and an empty
// description. WithSummary remains the fallback for a package the generator has
// not run over, so an undocumented op still names itself.
// Sorted by name, because this list is SERIALIZED — a host embeds it as a
// build-time catalogue — and an artifact ordered by registration churns on an
// edit that changed nothing a client can see.
func (a *App) mcpTools() []map[string]any {
return ProjectMCP(a.Manifest())
}
// tool runs a tools/call: find the op by name, invoke the SAME handler core the
// REST route uses, and return its JSON result as MCP text content. A handler
// error is reported as MCP isError content (not a JSON-RPC error), per the spec
// — the model sees the failure and can react.
func (a *App) tool(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
_ = json.Unmarshal(f.Params, &params)
op := a.opByName(params.Name)
if op == nil || op.invoke == nil {
// Not ours — a composed plugin may own it. Only a CALL wakes a child:
// tools/list answered from the catalogue and touched nothing.
if p := a.toolOwner(params.Name); p != nil {
return a.relay(ctx, f, p)
}
// Still nobody's: the name may be one that exists only for this caller,
// which no catalogue could have claimed. It goes to whoever offered the
// per-caller half of the list — this app's own Source, or the open plugin.
// Neither can be wrong about it: the same code that named the tool runs it.
if src := a.cfg.MCP.Source; src != nil {
out, err := src.Call(ctx, params.Name, params.Arguments)
return a.answer(ctx, f, out, err)
}
if p := a.openPlugin(); p != nil {
return a.relay(ctx, f, p)
}
return f.Fail(zapmcp.CodeParams, "unknown tool: "+params.Name)
}
if op.ans.stream == streamSocket {
return a.answer(ctx, f, nil, Errorf(501, "%s: %s", opName(op), NotACall))
}
// No URL over MCP: a tools/call carries every argument in its JSON arguments
// object, so the body IS the whole input — neither query nor path binds.
// The header reader comes off the ctx, so it answers honestly on a transport
// that has a request behind it and nothing on one that does not.
out, err := op.invoke(withOp(ctx, servedOp(op)), input{dec: jsonenc.Unmarshal, body: params.Arguments, media: mimeJSON, header: headerOf(ctx)})
// Bytes that state no type are the op's own media, as the REST door sends
// them, so a tool reads them the same way.
if b, ok := bodyOf(unwrap(out)); ok && err == nil && b.Type == "" && (len(b.Bytes) > 0 || b.Reader != nil) {
b.Type = produces(op, mimeOctet)
}
return a.answer(ctx, f, out, err)
}
// answer renders one tool result. A handler error is MCP isError content and not
// a JSON-RPC error, per the spec — the model sees the failure and can react. ONE
// renderer, so a typed op, a [Source] and a relayed plugin answer the same shape.
//
// An answer that is not one JSON value is read into content: bytes as text when
// their media type is text, as an image or an embedded resource's base64 blob
// otherwise, and an event stream as its events, one JSON line each — each
// bounded in bytes and a stream in time too, saying so in _meta when the bound
// cut it.
func (a *App) answer(ctx context.Context, f *zapmcp.Frame, out any, err error) *zapmcp.Frame {
if err != nil {
return f.Answer(mcpJSON(map[string]any{
"content": []map[string]any{{"type": "text", "text": err.Error()}},
"isError": true,
}))
}
out = unwrap(out)
if _, isBody := bodyOf(out); isBody {
return a.content(ctx, f, out)
}
if _, isStream := out.(stream); isStream {
return a.content(ctx, f, out)
}
text := "null"
if out != nil {
if b, mErr := jsonenc.Marshal(out); mErr == nil {
text = string(b)
}
}
return f.Answer(mcpJSON(map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
}))
}
// content answers a tools/call whose answer is bytes or a stream.
func (a *App) content(ctx context.Context, f *zapmcp.Frame, out any) *zapmcp.Frame {
var params struct {
Name string `json:"name"`
}
_ = json.Unmarshal(f.Params, &params)
result, err := toolContent(ctx, params.Name, out)
if err != nil {
return a.answer(ctx, f, nil, err)
}
return f.Answer(mcpJSON(result))
}
// toolOwner is the plugin that declared name in its catalogue, or nil.
func (a *App) toolOwner(name string) *plugin {
for _, h := range a.hosts() {
h.plugMu.Lock()
p := h.toolOwners[name]
h.plugMu.Unlock()
if p != nil {
return p
}
}
return nil
}
// tools is every composed plugin's build-time catalogue, gathered by walking.
//
// A plugin used to install its catalogue onto whatever host loaded it, so one
// map answered. A plugin is now a DEFINITION, so its catalogue lives with it and
// the host finds it the way it finds everything else about its composition. Same
// move the op registry made, and the same one Plugins/Reload/Unload made: one
// walk, and no projection keeps a private copy of what the program already says.
func (a *App) tools() []mcpTool {
var out []mcpTool
for _, h := range a.hosts() {
h.plugMu.Lock()
out = append(out, h.pluginTools...)
h.plugMu.Unlock()
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out
}
// relay hands the message to the plugin that owns the tool, at that plugin's own
// MCP door, over the transport it was composed on — for a Load'ed child, ZAP on
// its private unix socket. The plugin's own registry answers, so the host can
// only ever NAME a tool, never invoke one the child did not declare: a stale
// catalogue yields the child's own refusal, not a wrong call.
//
// This is the ONLY trigger that starts a process, and it starts exactly one:
// p.target() is the same single-flighted lazy path a prefix request takes.
//
// The message is RENDERED for the hop rather than the inbound request being
// passed through, which is what makes a relay possible at all from a door with
// no HTTP request behind it. The caller's identity rides along through
// [forwardIdentity] — the one function that forwards it — and nothing else does.
//
// A hop failure is MCP isError content rather than a transport error, per the
// spec: the model sees "this tool is not available right now" and can react,
// where a 503 body is a failure it cannot interpret.
func (a *App) relay(ctx context.Context, f *zapmcp.Frame, p *plugin) *zapmcp.Frame {
client, host, h := p.target()
body, err := json.Marshal(f)
if err != nil {
h.release()
return a.answer(ctx, f, nil, err)
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.Header.SetMethod(fasthttp.MethodPost)
req.Header.SetContentType(mimeJSON)
req.SetBody(body)
forwardIdentity(ctx, req)
if err := forward(ctx, req, resp, client, host, p.spec.mcpPath(), "mcp "+p.name, h); err != nil {
return a.answer(ctx, f, nil, err)
}
var ans zapmcp.Frame
if err := json.Unmarshal(resp.Body(), &ans); err != nil {
return a.answer(ctx, f, nil, fmt.Errorf("mcp %s: unreadable answer: %w", p.name, err))
}
// The child answered its own copy of the message; this hop owns the
// correlation, so the reply is stamped with what the CALLER sent.
ans.Kind, ans.Session, ans.Seq, ans.ID = zapmcp.Response, f.Session, f.Seq, f.ID
return &ans
}
func (a *App) opByName(name string) *registeredOp {
for _, op := range a.Registry() {
if opName(op) == name {
return op
}
}
return nil
}
// opName is the stable tool/operation id: the explicit OperationID, else the
// method+path default (shared with OpenAPI so the two surfaces agree).
func opName(op *registeredOp) string {
if op.OperationID != "" {
return op.OperationID
}
return ID(op.Method, op.Path)
}
// serveMCP brings the ZAP-native MCP listener up when this process owns an
// address for it. Called from Listen alongside the ops and peer listeners, so a
// plugin main ends with one verb and the app's own declaration decides whether
// there is another listener.
//
// It is [zapmcp.Server] wrapped around [App.MCP] and nothing else. That one line
// is the whole of "MCP rides ZAP natively": there is no adapter here because
// there is nothing to adapt — the door already speaks frames.
func (a *App) serveMCP() {
if a.sibling || a.cfg.MCP.Disabled {
return // a sibling serves no siblings
}
addr := strings.TrimSpace(a.cfg.MCP.Addr)
if addr == "" {
return
}
srv := &zapmcp.Server{Network: NetworkOf(addr), Addr: addr, Handler: a.zapDoor()}
a.mcpMu.Lock()
if a.mcpSrv != nil {
a.mcpMu.Unlock()
return // Listen may be called twice
}
a.mcpSrv = srv
a.mcpMu.Unlock()
a.logger.Info("zip mcp listening", "transport", "zap", "addr", addr)
go func() {
if err := srv.ListenAndServe(); err != nil {
a.logger.Error("zip mcp listener stopped", "addr", addr, "err", err)
}
}()
}
// zapDoor is [App.MCP] with this deployment's answer to "who is on this
// connection" bound in front of it, and it is the whole of what [MCPConfig.Identify]
// does. Without one the door is App.MCP itself, unchanged.
//
// It composes rather than configures: the door stays a value that takes a frame
// and answers one, and stating the caller is a wrapper around it — the same shape
// a deployment writes by hand when it serves App.MCP with a zapmcp.Server of its
// own. There is one door and one way to say who is at it.
func (a *App) zapDoor() zapmcp.Handler {
id := a.cfg.MCP.Identify
if id == nil {
return a.MCP
}
return func(ctx context.Context, f *zapmcp.Frame) *zapmcp.Frame {
return a.MCP(WithCaller(ctx, id(ctx)), f)
}
}
// checkMCP refuses a door this process cannot honour: a ZAP address over a
// surface with a per-caller half, and nothing to say who is calling.
//
// Such a door comes up, binds, logs `per-caller: true`, answers every frame and
// probes healthy — while silently serving only the build-time bytes, because
// [App.list] returns early on a caller it cannot name. The tools it drops are
// exactly the ones a Source exists to supply, which makes it the failure that
// looks most like success. A refusal at Listen costs one boot and names both
// fixes; the alternative is discovering it from an agent that cannot see a
// tenant's own tools and no line anywhere saying why.
func (a *App) checkMCP() error {
if a.sibling || a.cfg.MCP.Disabled || a.cfg.MCP.Identify != nil {
return nil
}
if strings.TrimSpace(a.cfg.MCP.Addr) == "" || !a.hasCaller() {
return nil
}
return fmt.Errorf("zip: MCP.Addr %q serves a door with a per-caller half and no MCP.Identify: "+
"a frame carries no gateway assertion, so every caller on that socket is anonymous and Source answers nothing. "+
"Set MCP.Identify to read the caller off the connection, or drop MCP.Addr and serve the door over HTTP",
a.cfg.MCP.Addr)
}
// closeMCP stops the ZAP MCP listener. Called from Shutdown alongside
// closeServers.
func (a *App) closeMCP() {
a.mcpMu.Lock()
srv := a.mcpSrv
a.mcpSrv = nil
a.mcpMu.Unlock()
if srv != nil {
_ = srv.Close()
}
}