mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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.
923 lines
37 KiB
Go
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, ¶ms)
|
|
|
|
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, ¶ms)
|
|
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()
|
|
}
|
|
}
|