Files
zeekay ebb52ca808 Take a body's content or refuse it; keep the graph to plain ops
An op that takes the body itself (a Body field, a form, a Parser) read
it through fiber's Body, which answers a Content-Encoding it cannot
undo with the error's text in place of the bytes, so the handler ran on
"gzip: invalid header". It now reads the content: each coding undone
and held to BodyLimit, 415 for a coding the server does not undo, 413
past the limit and 400 for one that does not decode, all before the
handler runs. A form is read from that content, so a gzip form binds
its fields and a multipart form is parsed in memory from a body the
limit already bounds; a small request can no longer decode into a part
of any size. A JSON op reads fiber's Body as before.

An answer refused after its handler ran (an undeclared status or
header) closes its Reader, so a relay no longer leaks the upstream
body.

The graph carries only plain ops, one JSON value in and one out: bytes,
forms, Parsers, streams, sockets, redirects, relays and unions are no
field, so none of their handlers runs there without the checks its own
door makes. A cookie: field is never a graph argument, and Here refuses
a socket op before its handler, as the call plane and MCP do.
2026-10-09 10:29:22 -07:00

884 lines
28 KiB
Go

// SPDX-License-Identifier: BSD-3-Clause-Eco
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zip
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/ws"
)
// The answers a typed op may give besides one JSON value. Each is the Out of an
// ordinary zip.Get/Post/…, so the op keeps its schema, its prose, its MCP tool,
// its CLI command and its place in every SDK, and the document says what the
// wire carries: bytes under their media type, an event stream and the type of
// one event, an upgrade and the type of one message, a redirect and its
// Location.
// Verbatim is a [Body] documented as T: a relay's answer, written exactly as
// the upstream sent it, while the document, the SDKs and the tool describe the
// upstream's 2xx shape T.
//
// Its status is the upstream's. A 2xx or 3xx must be one the op declared, as
// for any answer; a 4xx or 5xx passes through undeclared, because it is the
// upstream's refusal in the upstream's own words, and the document says so
// beside the op's own refusal. Verbatim[Sse[E]] relays an event stream and is
// documented as one whose events are E.
type Verbatim[T any] struct{ Body }
// documents is the type a Verbatim is documented as.
func (Verbatim[T]) documents() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }
// Sse is an answer written as server-sent events (text/event-stream), one
// [Event] per frame, each written and flushed as Send produces it.
//
// return &zip.Sse[Chunk]{Keep: 15 * time.Second, Send: func(ctx context.Context, emit func(zip.Event[Chunk]) error) error {
// for {
// select {
// case chunk, ok := <-chunks:
// if !ok {
// return emit(zip.Event[Chunk]{Text: "[DONE]"})
// }
// if err := emit(zip.Event[Chunk]{Data: chunk}); err != nil {
// return err // the client is gone
// }
// case <-ctx.Done():
// return ctx.Err()
// }
// }
// }}, nil
//
// Send runs after the handler returns, once the status and headers are out, on
// a goroutine of its own. Its ctx ends when the stream does — a write found the
// client gone (so in a quiet stream only Keep finds it), or a tool call has
// collected all it will — and a producer that waits on anything selects on it. An error from emit means the stream
// has ended, and Send should return it. An error Send returns ends the stream
// where it stands: the status already went out, so it cannot be a refusal.
//
// The document publishes text/event-stream with the event's type as x-events.
// Over MCP and the CLI the events are the result, one JSON line each.
type Sse[T any] struct {
// Send produces the events, calling emit once per event in order, until
// it is done or ctx ends.
Send func(ctx context.Context, emit func(Event[T]) error) error `zap:"-"`
// Keep writes an empty comment line whenever this long passes with no
// event, so a proxy keeps a waiting stream open and a client that left is
// found by the write. Zero writes none.
Keep time.Duration
// Header are the answer's headers beside Content-Type, each one the op
// declared ([WithResponseHeader]).
Header map[string]string `zap:"-"`
}
// ResponseHeaders are the headers the stream states.
func (s Sse[T]) ResponseHeaders() map[string]string { return s.Header }
// frame is the type one event carries.
func (Sse[T]) frame() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }
// Event is one server-sent event. Data is written as one line of JSON; Text,
// when it is not empty, is written in its place as it is (a sentinel such as
// [DONE] is text, not a value). A line break in Text, Event or ID cannot start
// a field of its own: Text becomes several data lines, Event and ID lose theirs.
type Event[T any] struct {
// Event is the event's name, the field a browser dispatches on.
Event string
// ID is the id a reconnecting client sends back as Last-Event-ID.
ID string
// Retry is how long, in milliseconds, a client waits before reconnecting.
Retry int
// Data is the event's value.
Data T
// Text is the event's data as written, in place of Data.
Text string
}
// payload is the event's data as the stream carries it.
func (e Event[T]) payload() ([]byte, error) {
if e.Text != "" {
return []byte(e.Text), nil
}
return jsonenc.Marshal(e.Data)
}
// encode is the event as the bytes text/event-stream carries.
func (e Event[T]) encode() ([]byte, error) {
data, err := e.payload()
if err != nil {
return nil, err
}
var b strings.Builder
if e.Event != "" {
b.WriteString("event: " + oneLine(e.Event) + "\n")
}
if e.ID != "" {
b.WriteString("id: " + oneLine(e.ID) + "\n")
}
if e.Retry > 0 {
b.WriteString("retry: " + strconv.Itoa(e.Retry) + "\n")
}
for _, line := range sseLines(string(data)) {
b.WriteString("data: " + line + "\n")
}
b.WriteByte('\n')
return []byte(b.String()), nil
}
// keepLine is what a quiet interval writes: an empty comment, as zip in Rust
// writes it.
var keepLine = []byte(":\n\n")
// sseLines splits on every line break the event-stream format knows.
func sseLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.Split(s, "\n")
}
func oneLine(s string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(s) }
// events runs Send, handing each event to frame as the bytes the stream carries
// (wire) or as its data alone. Once ctx ends, emit answers errGone.
func (s *Sse[T]) events(ctx context.Context, wire bool, frame func([]byte) error) (err error) {
if s.Send == nil {
return nil
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("zip: an event stream panicked: %v", r)
}
}()
return s.Send(ctx, func(e Event[T]) error {
if ctx.Err() != nil {
return errGone
}
var b []byte
var err error
if wire {
b, err = e.encode()
} else {
b, err = e.payload()
}
if err != nil {
return err
}
return frame(b)
})
}
// stream is how the REST door writes any event stream.
type stream interface {
events(ctx context.Context, wire bool, frame func([]byte) error) error
keepEvery() time.Duration
}
func (s *Sse[T]) keepEvery() time.Duration { return s.Keep }
// errGone is what emit answers once the stream has ended: the client stopped
// reading, or whoever was collecting it stopped.
var errGone = errors.New("zip: the client is gone")
// pump runs a stream's producer beside its sink and hands the sink each frame
// as it is produced — the bytes text/event-stream carries when wire, else the
// event's data alone — and keepLine whenever keep passes with none. It returns
// when the producer ends, the sink refuses a frame, or ctx ends; in the last two
// cases the producer's ctx is cancelled, so its next emit answers errGone.
func pump(ctx context.Context, s stream, wire bool, keep time.Duration, sink func([]byte) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
frames := make(chan []byte)
ended := make(chan error, 1)
go func() {
ended <- s.events(ctx, wire, func(b []byte) error {
select {
case frames <- b:
return nil
case <-ctx.Done():
return errGone
}
})
}()
var tick <-chan time.Time
var timer *time.Timer
if keep > 0 {
timer = time.NewTimer(keep)
defer timer.Stop()
tick = timer.C
}
for {
select {
case b := <-frames:
if err := sink(b); err != nil {
return err
}
if timer != nil {
timer.Reset(keep)
}
case <-tick:
if err := sink(keepLine); err != nil {
return err
}
timer.Reset(keep)
case err := <-ended:
return err
case <-ctx.Done():
return ctx.Err()
}
}
}
// writeEvents writes a stream to w, flushing each event, with a keep-alive
// comment in every quiet interval when the stream asks for one. A write that
// fails is the client gone, and ends the stream.
func writeEvents(ctx context.Context, s stream, w *bufio.Writer) error {
return pump(ctx, s, true, s.keepEvery(), func(b []byte) error {
if _, err := w.Write(b); err != nil {
return errGone
}
if w.Flush() != nil {
return errGone
}
return nil
})
}
// Socket is an answer that upgrades the connection to a WebSocket whose
// messages are M. The op validates, authorizes and binds its input like any
// other; then the connection is upgraded and Serve runs on it until it
// returns.
//
// return &zip.Socket[Frame]{Serve: func(conn *wsx.Conn) error { … }}, nil
//
// A request that cannot become a connection is refused before the handler
// runs, so a handler with effects runs only for a connection it will get: over
// ZAP, which carries no upgrade, 501; one that does not ask to upgrade, 426 with
// Upgrade: websocket; one asking for another protocol version, 426 with
// Sec-WebSocket-Version: 13; one with no Sec-WebSocket-Key, 400; and a browser
// on an origin the op does not admit ([Origins]), 403 — a socket op may read a
// cookie, which a browser sends cross-site too. The document publishes 101 with
// M as x-events and marks the op x-socket: websocket. A connection is not a
// call, so the op is no MCP tool and has no call-plane method; the CLI bridges
// it to stdin and stdout.
type Socket[M any] struct {
// Serve runs on the upgraded connection.
Serve func(conn *ws.Conn) error `zap:"-"`
// Subprotocols are the protocols the op speaks, in its order of
// preference; the first the client also offers is selected.
Subprotocols []string `zap:"-"`
// Compression negotiates per-message deflate when the client offers it.
Compression bool `zap:"-"`
}
// Origins names the browser origins, besides the address's own, that may open
// an op's WebSocket: "https://app.example", or "*" for any. Without it only the
// address's own origin may; a request carrying no Origin is not a browser's
// and is always admitted. The handler runs only for an origin admitted.
func Origins(origins ...string) OpOption {
return func(op *registeredOp) { op.Origins = append([]string(nil), origins...) }
}
// frame is the type one message carries.
func (Socket[M]) frame() reflect.Type { return reflect.TypeOf((*M)(nil)).Elem() }
// serve upgrades a connection the REST door already found upgradable.
func (s *Socket[M]) serve(c fiber.Ctx) error {
serve := s.Serve
cfg := ws.Config{
Subprotocols: s.Subprotocols,
EnableCompression: s.Compression,
// The origin was admitted before the handler ran ([upgradable]).
CheckOrigin: func(*fasthttp.RequestCtx) bool { return true },
}
return ws.Upgrade(c.RequestCtx(), cfg, func(conn *ws.Conn) {
if serve != nil {
_ = serve(conn)
}
})
}
// upgradable refuses a request that cannot become a WebSocket the op will
// serve, before its handler runs: one carried over ZAP, which runs no upgrade
// (501); one that does not ask (426, with Upgrade: websocket); one asking for
// another protocol version (426, with the one spoken); one with no key (400);
// and a browser on an origin the op does not admit (403).
func upgradable(c fiber.Ctx, origins []string) error {
rc := c.RequestCtx()
if carriedByZAP(rc) {
return Errorf(http.StatusNotImplemented, "%s %s upgrades to a WebSocket, which does not cross ZAP; connect over HTTP", c.Method(), c.Path())
}
if !ws.Is(rc) {
c.Set("Upgrade", "websocket")
return Errorf(http.StatusUpgradeRequired, ws.Refusal)
}
if !ws.Speaks(rc) {
c.Set("Sec-WebSocket-Version", ws.Version)
return Errorf(http.StatusUpgradeRequired, "this address speaks WebSocket version %s", ws.Version)
}
if len(rc.Request.Header.Peek("Sec-WebSocket-Key")) == 0 {
return ErrBadRequest("a WebSocket handshake carries a Sec-WebSocket-Key")
}
if !ws.Admits(rc, origins) {
return Errorf(http.StatusForbidden, "%s may not open a connection here", rc.Request.Header.Peek("Origin"))
}
return nil
}
// upgrader is a [Socket], whatever its message type.
type upgrader interface{ serve(c fiber.Ctx) error }
// NotACall is why an op that answers a [Socket] is no MCP tool and no call:
// the op's answer is a connection that stays open.
const NotACall = "a connection is not a call"
// Redirect is an answer that sends the client elsewhere: Status (a 3xx the op
// declared; zero is the first it declared, 302 when it declared none) with
// Location set to To and no body. The document publishes the status and its
// Location header; MCP and the CLI answer the location.
//
// A type that embeds Redirect is a redirect too, so one that also sets cookies
// is a struct embedding it with a Cookies method ([CookieCoder]): the sign-in
// leg that clears one cookie and sets another answers both.
type Redirect struct {
// To is the location the client is sent to.
To string `json:"to"`
// Status is the redirect's status.
Status int `json:"status,omitempty"`
}
// StatusCode is the status the redirect states; zero is the op's own.
func (r Redirect) StatusCode() int { return r.Status }
// redirect is the Redirect an answer is, promoted to a type that embeds one.
func (r *Redirect) redirect() *Redirect { return r }
// redirector is an answer that is a [Redirect], or embeds one.
type redirector interface{ redirect() *Redirect }
// CookieCoder is an answer that sets cookies: one Set-Cookie per cookie, which
// a header map cannot say. An op whose answer is one declares Set-Cookie by
// being one.
type CookieCoder interface{ Cookies() []*http.Cookie }
var cookieCoder = reflect.TypeOf((*CookieCoder)(nil)).Elem()
// Produces names the media types an op answers with when they are not
// application/json: application/scim+json, application/jwk-set+json, or the
// types of the bytes a [Body] answers. The first is what a JSON answer is sent
// as.
func Produces(media ...string) OpOption {
if len(media) == 0 {
panic("zip: Produces needs at least one media type")
}
return func(op *registeredOp) { op.Produces = append([]string(nil), media...) }
}
// Answer kinds a manifest names an op's stream by.
const (
streamSSE = "sse"
streamBytes = "bytes"
streamSocket = "socket"
)
// answer is what an op's Out may put on the wire, read once from its type. A
// union ([Or], or a type with a OneOf method) is flattened into the kinds its
// alternatives are.
type answer struct {
// json are the JSON values the op may answer: one, or several when it is a
// union. union is the union's own type when it is one that writes itself
// (a OneOf method); an [Or]'s alternatives are flattened into json.
json []reflect.Type
union reflect.Type
// stream is "sse", "bytes" or "socket" when the op may answer one, and
// frame the type of one event or message.
stream string
frame reflect.Type
// verbatim says the answer is bytes relayed as sent, documented as json.
verbatim bool
// redirect says the op may answer a [Redirect].
redirect bool
// cookies says the answer may set cookies ([CookieCoder]).
cookies bool
// statuses are the statuses the alternatives state for themselves, by type.
statuses map[reflect.Type]int
}
// answerOf reads an Out type's answer kinds.
func answerOf(out reflect.Type) answer {
a := answer{statuses: map[reflect.Type]int{}}
if out == nil {
return a
}
a.add(out, true)
return a
}
func (a *answer) add(t reflect.Type, top bool) {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Implements(cookieCoder) || reflect.PointerTo(t).Implements(cookieCoder) {
a.cookies = true
}
v := reflect.New(t).Interface()
switch x := v.(type) {
case interface{ documents() reflect.Type }:
a.verbatim = true
d := x.documents()
if f, ok := reflect.New(d).Interface().(interface{ frame() reflect.Type }); ok {
if _, sse := reflect.New(d).Interface().(stream); sse {
a.setStream(streamSSE, f.frame())
return
}
}
a.json = append(a.json, d)
return
case stream:
a.setStream(streamSSE, v.(interface{ frame() reflect.Type }).frame())
return
case upgrader:
a.setStream(streamSocket, v.(interface{ frame() reflect.Type }).frame())
return
case answerBody:
a.setStream(streamBytes, nil)
return
case redirector:
a.redirect = true
return
}
if alts := alternatives(t); len(alts) > 0 {
if !isOr(t) {
// A union of JSON values decoded by its own type: each
// alternative is a schema, the value is written as it marshals.
a.union = t
for _, alt := range alts {
a.json = append(a.json, alt)
a.state(alt)
}
return
}
for _, alt := range alts {
a.add(alt, false)
}
return
}
a.json = append(a.json, t)
if !top {
a.state(t)
}
}
// state records the status an alternative states for itself: the value
// StatusCode of its zero value.
func (a *answer) state(t reflect.Type) {
if sc, ok := reflect.Zero(t).Interface().(StatusCoder); ok {
if code := sc.StatusCode(); code != 0 {
a.statuses[t] = code
}
}
}
func (a *answer) setStream(kind string, frame reflect.Type) {
if a.stream != "" && a.stream != kind {
panic(fmt.Sprintf("zip: an answer may be one kind of stream, not both %s and %s", a.stream, kind))
}
a.stream, a.frame = kind, frame
}
// settle completes an op's declaration from its answer: the statuses its
// alternatives and its redirects state, and the headers a redirect and a cookie
// set, so the document and the seam agree on them without the author repeating
// what the type already says.
func (a answer) settle(op *registeredOp) {
if a.stream == streamSocket {
if op.Method != http.MethodGet {
panic(fmt.Sprintf("zip: %s %s answers a WebSocket, which a client opens with GET", op.Method, op.Path))
}
if len(a.json) > 0 || a.redirect {
panic(fmt.Sprintf("zip: %s %s answers a WebSocket and something else; an upgrade is the whole answer", op.Method, op.Path))
}
}
declared := len(op.Statuses) > 0
primary := 200
if declared {
primary = op.Statuses[0]
}
want := []int{}
for _, code := range a.statuses {
want = append(want, code)
}
if a.redirect {
has := false
for _, code := range op.Statuses {
if code >= 300 && code < 400 {
has = true
}
}
if !has {
want = append(want, http.StatusFound)
}
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Location")
}
if a.cookies {
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Set-Cookie")
}
if a.stream == streamBytes || a.verbatim {
op.ResponseHeaders = addHeader(op.ResponseHeaders, "Content-Disposition")
}
for _, code := range want {
if containsInt(op.Statuses, code) {
continue
}
if declared && !(a.redirect && code == http.StatusFound) {
panic(fmt.Sprintf("zip: %s %s answers %d, which its WithStatus does not declare", op.Method, op.Path, code))
}
if len(op.Statuses) == 0 && (len(a.json) > 0 || a.stream != "") {
op.Statuses = append(op.Statuses, primary)
}
op.Statuses = append(op.Statuses, code)
}
}
func addHeader(list []string, name string) []string {
for _, h := range list {
if strings.EqualFold(h, name) {
return list
}
}
return append(list, name)
}
func containsInt(list []int, v int) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// bodyOf is the [Body] an answer carries, when it carries one: a Body, a
// Verbatim, or any type that embeds a Body.
func bodyOf(out any) (*Body, bool) {
if b, ok := out.(answerBody); ok {
if v := reflect.ValueOf(out); v.Kind() == reflect.Pointer && !v.IsNil() {
return b.body(), true
}
}
return nil, false
}
// unwrap is the alternative a union answer holds, or the answer itself.
func unwrap(out any) any {
for {
u, ok := out.(interface{ either() any })
if !ok {
return out
}
out = u.either()
}
}
// writeAnswer writes an op's answer over REST. It is the one place the kinds
// above reach the wire.
func writeAnswer(c fiber.Ctx, op *registeredOp, out any) error {
out = unwrap(out)
if out == nil {
code, err := statusOf(op, nil, false)
if err != nil {
return err
}
c.Status(nonZero(code, http.StatusNoContent))
return nil
}
if s, ok := out.(upgrader); ok {
return s.serve(c)
}
b, isBody := bodyOf(out)
hdrs, err := responseHeadersOf(op, out)
if err != nil {
b.release()
return err
}
code, err := statusOf(op, out, op.ans.verbatim)
if err != nil {
b.release()
return err
}
if cc, ok := out.(CookieCoder); ok {
for _, ck := range cc.Cookies() {
if ck == nil {
continue
}
if v := ck.String(); v != "" {
c.Response().Header.Add("Set-Cookie", v)
}
}
}
for name, v := range hdrs {
c.Set(name, v)
}
switch x := out.(type) {
case redirector:
c.Set("Location", x.redirect().To)
c.Status(nonZero(code, http.StatusFound))
return nil
case stream:
if code != 0 {
c.Status(code)
}
c.Set(fiber.HeaderContentType, "text/event-stream")
// The stream outlives the handler, so its ctx is its own: it ends when a
// write finds the client gone, or when the producer is done. A quiet
// stream finds a client that left only through Keep.
return c.SendStreamWriter(func(w *bufio.Writer) {
defer func() { _ = recover() }()
_ = writeEvents(context.Background(), x, w)
})
}
if code != 0 {
c.Status(code)
}
if isBody {
// No bytes and no stated type is no Content-Type: a 304 or an empty
// answer carries none, as a handler that wrote nothing never did.
media := b.Type
if media == "" && (len(b.Bytes) > 0 || b.Reader != nil) {
media = produces(op, mimeOctet)
}
if media != "" {
c.Set(fiber.HeaderContentType, media)
}
if b.Name != "" {
c.Set(fiber.HeaderContentDisposition, disposition(b.Name))
}
if b.Reader != nil {
r := b.Reader
return c.SendStreamWriter(func(w *bufio.Writer) { copyFlushing(w, r) })
}
return c.Send(b.Bytes)
}
// The framework's own JSON media (with its charset) unless the op names
// another for its JSON: an op that declares nothing answers as it always
// has, and one whose Produces names the media of its bytes keeps JSON for
// its JSON.
if media := produces(op, mimeJSON); media != mimeJSON {
return c.JSON(out, media)
}
return c.JSON(out)
}
// disposition is the Content-Disposition that saves an answer as name: the
// name quoted, its quote and backslash escaped, and any control character —
// which could end the header and start another — dropped.
func disposition(name string) string {
var b strings.Builder
b.WriteString(`attachment; filename="`)
for _, r := range name {
switch {
case r < 0x20 || r == 0x7f:
continue
case r == '"' || r == '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
b.WriteByte('"')
return b.String()
}
// copyFlushing writes r to w a read at a time, flushing each, so bytes reach
// the client as the source yields them. It closes r when it can. It runs on the
// server's stream goroutine, where nothing above it recovers, so a reader that
// panics ends the answer where it stands instead of the process.
func copyFlushing(w *bufio.Writer, r io.Reader) {
defer func() { _ = recover() }()
if c, ok := r.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
buf := make([]byte, 32<<10)
for {
n, err := r.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
if w.Flush() != nil {
return
}
}
if err != nil {
return
}
}
}
// produces is the media a kind of answer is sent as: the op's first Produces,
// else the kind's own.
func produces(op *registeredOp, dflt string) string {
if len(op.Produces) > 0 && (dflt == mimeJSON) == (op.ans.stream != streamBytes) {
return op.Produces[0]
}
return dflt
}
func nonZero(code, dflt int) int {
if code == 0 {
return dflt
}
return code
}
// toolBound is the most a tool result carries of an answer that is bytes or a
// stream. A tool result is read into a model's context in one piece, so an
// answer past it is cut at a frame or at the bound and says so.
const toolBound = 1 << 20
// errBound ends a collection at the tool bound.
var errBound = errors.New("zip: the tool result bound was reached")
// toolWait is the longest a tool call collects a stream for: under the
// sixty-second request timeout the reference MCP clients default to, so the
// answer arrives while someone is still waiting for it.
const toolWait = 45 * time.Second
// collect reads an answer that is not one JSON value into what a call returns:
// bytes with their media type, or a stream's events as JSON lines, bounded in
// bytes and a stream in time as well. truncated says a bound was reached
// first; the producer of a stream cut short sees its ctx end.
func collect(ctx context.Context, out any) (data []byte, media string, truncated bool, err error) {
if s, ok := out.(stream); ok {
var b []byte
ctx, cancel := context.WithTimeout(ctx, toolWait)
defer cancel()
err = pump(ctx, s, false, 0, func(frame []byte) error {
if len(b)+len(frame)+1 > toolBound {
return errBound
}
b = append(append(b, frame...), '\n')
return nil
})
if errors.Is(err, errBound) || errors.Is(err, context.DeadlineExceeded) {
return b, "application/jsonl", true, nil
}
return b, "application/jsonl", false, err
}
b, _ := bodyOf(out)
if b.Reader == nil {
if len(b.Bytes) > toolBound {
return b.Bytes[:toolBound], b.Type, true, nil
}
return b.Bytes, b.Type, false, nil
}
ctx, cancel := context.WithTimeout(ctx, toolWait)
defer cancel()
data, truncated, err = readBounded(ctx, b.Reader)
return data, b.Type, truncated, err
}
// readBounded reads r to its end, toolBound bytes or ctx's end, whichever
// comes first, and closes it when it can. The read runs beside the wait, so a
// reader that never yields cannot hold the call; closing it is what ends a read
// left blocked.
func readBounded(ctx context.Context, r io.Reader) (data []byte, truncated bool, err error) {
c, closes := r.(io.Closer)
if closes {
defer func() { _ = c.Close() }()
}
chunks := make(chan []byte)
ended := make(chan error, 1)
go func() {
buf := make([]byte, 32<<10)
for {
n, rerr := r.Read(buf)
if n > 0 {
select {
case chunks <- append([]byte(nil), buf[:n]...):
case <-ctx.Done():
return
}
}
if rerr != nil {
if rerr == io.EOF {
rerr = nil
}
ended <- rerr
return
}
}
}()
for {
select {
case chunk := <-chunks:
if len(data)+len(chunk) > toolBound {
return append(data, chunk[:toolBound-len(data)]...), true, nil
}
data = append(data, chunk...)
case err := <-ended:
return data, false, err
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return data, true, nil
}
return data, false, ctx.Err()
}
}
}
// textual reports whether bytes of a media type read as text.
func textual(media string) bool {
m := strings.ToLower(strings.TrimSpace(strings.Split(media, ";")[0]))
switch {
case m == "", strings.HasPrefix(m, "text/"):
return true
case strings.HasSuffix(m, "+json"), strings.HasSuffix(m, "+xml"), strings.HasSuffix(m, "+yaml"):
return true
}
switch m {
case mimeJSON, "application/jsonl", "application/x-ndjson", "application/xml", "application/yaml",
"application/x-yaml", "application/javascript", "application/graphql", mimeForm:
return true
}
return false
}
// toolContent is an answer that is not one JSON value, as MCP tool content:
// text as text, an image as an image, any other bytes as an embedded resource
// whose blob is the base64 of them.
func toolContent(ctx context.Context, id string, out any) (map[string]any, error) {
data, media, truncated, err := collect(ctx, out)
if err != nil {
return nil, err
}
var content map[string]any
switch {
case textual(media):
content = map[string]any{"type": "text", "text": string(data)}
case strings.HasPrefix(media, "image/"):
content = map[string]any{"type": "image", "data": base64Text(data), "mimeType": media}
default:
content = map[string]any{"type": "resource", "resource": map[string]any{
"uri": "zip:" + id, "mimeType": media, "blob": base64Text(data),
}}
}
result := map[string]any{"content": []map[string]any{content}}
if truncated {
result["_meta"] = map[string]any{"truncated": true, "bound": toolBound}
}
return result, nil
}