moduleproto

package
v0.38.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package moduleproto implements the wire protocol between the GoFastr host process and an out-of-process third-party module, as specified by issue #37 (design §4). It is the single source of truth for framing, the bidirectional Frame envelope, request/response correlation, version negotiation, and the method catalog's typed value shapes.

The package is deliberately transport- and policy-neutral. It carries wire mechanics only:

  • It does NOT import github.com/DonaldMurillo/gofastr/framework or any framework subpackage, and it does NOT import core/mcp. The host↔module pipe is a purpose-built JSON-RPC 2.0 dialect, not MCP. (MCP survives only as an optional module *surface*, registered into a separate core/mcp.Server by the supervisor — never on this pipe.)
  • It enforces NO capability rules. The resource:verb intersection (module-grant ∩ caller-authority) lives in the supervisor above this codec.
  • The codec reads/writes an io.Reader/io.Writer pair — not os.Stdin / os.Stdout specifically — so the wire format survives a future v2 socket transport without re-opening the protocol (design §1).

Why not reuse framework/harness/mcpclient

mcpclient structurally cannot dispatch child-originated requests: its read loop unmarshals every inbound line into a response struct with no Method field and treats id==0 as a notification sentinel. Once ids flow both ways (the host originates module.* requests AND the child originates host.* reverse requests over the SAME pipe), those assumptions misroute frames. moduleproto builds a fresh, symmetric, full-duplex codec instead.

The load-bearing property: per-direction ID correlation

Each endpoint owns an independent monotonic id counter starting at 1. Host-originated id:7 and child-originated id:7 NEVER collide: a reader consults its local pending map ONLY for frames that are responses (method == "" && id present). A request with the same numeric id is handled by the request branch, which echoes the id back without touching the map. This is pinned by an interleaved bidirectional test in peer_test.go.

Frame envelope (design §4.3)

One bidirectional Frame, discriminated per JSON-RPC 2.0 by the presence of method:

Frame {
  jsonrpc: "2.0"          // required; rejected otherwise
  id?:     uint64         // present ⇒ request or response; absent ⇒ notification
  method?: string         // present ⇒ REQUEST; absent ⇒ RESPONSE
  params?: raw            // requests only
  result?: raw            // success responses only
  error?:  {code,message,data?}
}

id:0 is impossible on the wire: id counters start at 1, and UnmarshalJSON rejects a decoded id of 0. Notifications omit the id key entirely (no omitempty-zero sentinel).

Method catalog

See MethodHandshake and the rest of the method constants for the full host→module (module.*) and module→host reverse (host.*) catalog. Params and results are pure value types in this package; the host broker above fills in capability and delegation semantics.

Index

Constants

View Source
const (
	// DefaultMaxFrameBytes is the default negotiated max_frame_bytes (1 MiB),
	// mirroring core/mcp's maxMCPBodyBytes = 1<<20. A frame whose
	// JSON-encoded length exceeds this is rejected as terminal on both ends.
	DefaultMaxFrameBytes = 1 << 20

	// DefaultMaxInflight is the default per-peer cap on in-flight originated
	// requests (design §8: 32 leases/child). Over-cap is a clean
	// call-local error ([ErrInflightCap]), not a protocol fault.
	DefaultMaxInflight = 32
)

Codec cap constants (design §4.2). The scanner caps are the verbatim lift from mcpclient: 64 KiB initial buffer growing to a 4 MiB ceiling. The negotiated max_frame_bytes (default 1 MiB) is the protocol-level cap, enforced on BOTH read and write — over-cap is a terminal fault, never a truncation.

View Source
const (
	// CodeParseError is JSON-RPC's -32700: invalid JSON was received.
	CodeParseError = -32700
	// CodeInvalidRequest is JSON-RPC's -32600: the JSON is not a valid Request.
	CodeInvalidRequest = -32600
	// CodeMethodNotFound is JSON-RPC's -32601.
	CodeMethodNotFound = -32601
	// CodeInvalidParams is JSON-RPC's -32602.
	CodeInvalidParams = -32602
	// CodeInternalError is JSON-RPC's -32603.
	CodeInternalError = -32603

	// CodeOvercap is moduleproto-specific: a frame exceeded the negotiated
	// max_frame_bytes on read or write. It is a terminal protocol fault; the
	// peer is torn down. The implementation-defined error band is -32099 to
	// -32000 per JSON-RPC 2.0.
	CodeOvercap = -32099
	// CodeInflightCap indicates the originating peer's in-flight cap is
	// reached; the caller should retry or shed load. Not terminal.
	CodeInflightCap = -32098
	// CodeHandshakeMismatch indicates a digest or identity mismatch during
	// module.handshake. Terminal — a restart cannot fix a bad artifact.
	CodeHandshakeMismatch = -32097
	// CodeCapabilityDenied indicates the host's capability broker refused a
	// reverse host.* call: the module-grant pre-filter (access.ScopeMatch),
	// the CrossOwnerRead carve-out, the delegation-handle lookup, or the
	// re-dispatch caller-authority gate (401/403 from the CRUD chokepoint)
	// failed closed. NOT a protocol fault — the connection stays up; the
	// child receives a per-call denial it must surface in its own response.
	// (design #37 §5; the trust boundary.)
	CodeCapabilityDenied = -32096
)

Standard JSON-RPC 2.0 error codes (the -32xxx reserved band), plus moduleproto-specific codes in the implementation-defined range.

View Source
const (
	// Host → module.
	MethodHandshake = "module.handshake"
	MethodHTTP      = "module.http"
	MethodReady     = "module.ready"
	MethodHealth    = "module.health"
	MethodDrain     = "module.drain"
	MethodToolList  = "module.tool.list"
	MethodToolCall  = "module.tool.call"

	// Module → host (reverse).
	MethodHostEntityQuery  = "host.entity.query"
	MethodHostEntityCreate = "host.entity.create"
	MethodHostEntityUpdate = "host.entity.update"
	MethodHostEntityDelete = "host.entity.delete"
	MethodHostSearchQuery  = "host.search.query"
	MethodHostEventEmit    = "host.event.emit"

	// Notifications.
	MethodCancel = "module.cancel"
)

Method name constants for the moduleproto catalog (design §4.4).

Host → module (requests):

  • MethodHandshake: initialize the connection, negotiate proto, cross-check digests.
  • MethodHTTP: proxy a single HTTP request to the module's route surface.
  • MethodReady: one-time warmup probe (distinct from liveness).
  • MethodHealth: ongoing liveness ping (idle-only).
  • MethodDrain: ask the child to finish in-flight work.
  • MethodToolList: optional MCP tool surface — list tools.
  • MethodToolCall: optional MCP tool surface — invoke a tool.

Module → host (reverse requests, each capability-checked by the supervisor's broker):

Notifications (no id):

  • MethodCancel: host → module per-call deadline cancellation.
View Source
const (
	// ProtoV1 is the single v1 protocol version number.
	ProtoV1 = 1
	// ProtoV1Min is the lowest version this package's Peer can speak.
	ProtoV1Min = 1
	// ProtoV1Max is the highest version this package's Peer can speak.
	ProtoV1Max = 1
)

Protocol versions understood by this package. Bump ProtoV1Max when a new version is added; Negotiate picks the highest mutually-supported one.

View Source
const DefaultRingSinkBytes = 64 * 1024

DefaultRingSinkBytes is the default capacity of RingSink (design §4.2: "bounded ring sink so child logs are observable without unbounded memory", 64 KiB mirroring the stderr observability budget).

Variables

View Source
var (
	// ErrClosed is returned by a Peer method after [Peer.Close] or once the
	// read loop has exited for any reason.
	ErrClosed = errors.New("moduleproto: peer closed")

	// ErrInflightCap is returned by [Peer.Call] when the peer is already
	// servicing the maximum number of in-flight originated requests. It is a
	// call-local error, not a protocol fault.
	ErrInflightCap = errors.New("moduleproto: inflight cap reached")

	// ErrInvalidFrame wraps any frame-level validation failure surfaced from
	// [Frame.UnmarshalJSON]. The underlying error carries the detail.
	ErrInvalidFrame = errors.New("moduleproto: invalid frame")

	// ErrNegotiation is returned by [Negotiate] when the two peers' proto
	// ranges do not intersect above the joint floor.
	ErrNegotiation = errors.New("moduleproto: proto negotiation failed")

	// ErrCriticalFeature is returned when a peer declares a critical feature
	// the other endpoint does not support.
	ErrCriticalFeature = errors.New("moduleproto: unsupported critical feature")
)

Sentinel errors. These describe protocol-fatal or call-local conditions. Terminal protocol faults (over-cap, unsolicited response, malformed frame) tear down the Peer; the supervisor maps them to a restart-vs-Failed classification (design §8).

View Source
var DefaultLimits = Limits{
	FrameBytes: DefaultMaxFrameBytes,
	DeadlineMs: 10_000,
	Inflight:   DefaultMaxInflight,
}

DefaultLimits is the v1 default Limits the host applies absent descriptor narrowing.

View Source
var DefaultProtoRange = ProtoRange{Min: ProtoV1Min, Max: ProtoV1Max}

DefaultProtoRange is the range advertised by a v1 moduleproto Peer that has not been narrowed by configuration.

View Source
var ErrScannerOvercap = errors.New("moduleproto: scanner buffer overflow")

ErrScannerOvercap wraps bufio.ErrTooLong so callers can distinguish a scanner-structural overflow from a negotiated-cap overflow via OvercapError.

Functions

func CheckCritical

func CheckCritical(supported, required []string) error

CheckCritical implements the critical-feature half of §4.5 negotiation.

In the handshake, a peer may declare names in `critical` for features it REQUIRES the other endpoint to support. The receiver rejects the handshake if any of the sender's critical names are absent from its own supported set (`supported`). Unknown non-critical fields are ignored — only critical names force rejection.

For v1 only the host→child direction is wired (the handshake result has no critical field). The supervisor calls CheckCritical(hostSupported, childCritical) when validating a child-declared critical set if/when one is added.

func EncodeJSON

func EncodeJSON(v any) (json.RawMessage, error)

EncodeJSON is a small helper for tests and for peers that need to encode a typed value as json.RawMessage for Frame.Params. It enforces nothing about size — callers composing raw Frames should check len against Codec.MaxFrameBytes.

func FeatureAck

func FeatureAck(a, b []string) []string

FeatureAck selects the intersection of two peers' advertised non-critical feature lists. This is the agreed feature set both sides may rely on. Names not in both lists are simply not available; they are not errors.

func Negotiate

func Negotiate(host, child ProtoRange) (int, error)

Negotiate implements the version intersection rule from design §4.5:

negotiated = min(host.Max, child.Max)
             if that value >= max(host.Min, child.Min)
             else terminal

Both ranges are validated first; a malformed range is terminal. The returned version is what both peers MUST use for the rest of the connection — it is the highest mutually-supported version that is also at or above the joint floor (the stricter of the two minimums).

Negotiate is pure — it makes no decision about feature policy; see CheckCritical for the critical-feature half of negotiation.

func WaitForReady

func WaitForReady(ctx context.Context, p *Peer, pollInterval time.Duration) error

WaitForReady polls module.ready until the child reports ready:true or until ctx is cancelled (design §4.7 step 4). It is the warmup gate. The poll interval defaults to 50ms if <= 0; callers should derive ctx from the 5s spawn deadline.

WaitForReady returns nil on ready:true, ctx.Err() on deadline, or the underlying Call error on protocol fault.

Types

type Caller

type Caller struct {
	// Subject is the resolved caller subject (user id, service id, …).
	Subject string `json:"subject,omitempty"`
	// Tenant is the resolved tenant id, if any.
	Tenant string `json:"tenant,omitempty"`
	// Delegation is the in-memory handle the host uses to re-attach the
	// originating request's context to a reverse host.* call.
	Delegation string `json:"delegation,omitempty"`
}

Caller is the resolved end-user context attached to a proxied call (and echoed by the child on reverse host.* requests so the host re-attaches the right request context to internal re-dispatch). The Delegation handle is an in-memory, replica-local opaque string minted by the host (§5: v1 needs no signed token; the handle never leaves shared memory).

type CancelParams

type CancelParams struct {
	RequestID string `json:"request_id"`
}

CancelParams is the body of the module.cancel notification (host → module). RequestID is the inbound request's host-side correlation id (the HTTPRequestParams.RequestID for a module.http call). The child uses it to find and cancel the in-flight handler's context.

type Codec

type Codec struct {
	// contains filtered or unexported fields
}

Codec is the full-duplex newline-delimited JSON framing layer (design §4.2).

It is transport-neutral: it speaks to an io.Reader and io.Writer pair, NOT specifically to os.Stdin / os.Stdout. The same Codec works for stdio (v1) and a future v2 socket transport — the wire format is identical, only the Reader/Writer change (design §1).

Concurrency:

  • Writes are mutex-guarded. Multiple goroutines may call Codec.WriteFrame concurrently; each frame is emitted atomically as JSON + '\n'.
  • Reads are NOT concurrency-safe: exactly one goroutine runs the read loop. This is the Peer's readLoop goroutine.

Over-cap policy (design §4.2): a frame exceeding max_frame_bytes on read or write produces an *OvercapError and is terminal — the peer tears down. The codec NEVER truncates; truncation would silently corrupt the wire.

func NewCodec

func NewCodec(r io.Reader, w io.Writer, maxFrameBytes int) (*Codec, error)

NewCodec constructs a Codec over the given reader/writer pair. If maxFrameBytes is <= 0, DefaultMaxFrameBytes is used. If maxFrameBytes is greater than the structural scanner cap (4 MiB), construction fails — the scanner itself would reject such frames at the wrong layer, so we surface the configuration error early rather than at first frame.

func (*Codec) MaxFrameBytes

func (c *Codec) MaxFrameBytes() int

MaxFrameBytes returns the negotiated frame cap in bytes.

func (*Codec) ReadFrame

func (c *Codec) ReadFrame() (*Frame, error)

ReadFrame reads the next newline-delimited frame. Returns io.EOF at a clean end-of-stream. Any decode error, scanner overflow, or negotiated-cap overflow is returned as a non-nil error that the Peer treats as terminal (with the exception of io.EOF, which may be graceful).

ReadFrame must be called from a single goroutine (the read loop).

func (*Codec) WriteFrame

func (c *Codec) WriteFrame(f *Frame) error

WriteFrame encodes f as JSON, enforces the negotiated cap, and writes the frame followed by a single '\n'. The write is mutex-guarded.

On over-cap the frame is NOT written and *OvercapError is returned — the caller (the Peer) treats this as a terminal protocol fault.

type DrainParams

type DrainParams struct {
	Reason     string `json:"reason"`
	DeadlineMs int    `json:"deadline_ms"`
}

DrainParams asks the child to wind down in-flight work (design §4.4 module.drain). The host's drain sequence issues this before killing the child.

type DrainResult

type DrainResult struct {
	Inflight int `json:"inflight"`
}

DrainResult reports the in-flight count at drain completion.

type EntityMutationParams

type EntityMutationParams struct {
	Entity  string          `json:"entity"`
	Payload json.RawMessage `json:"payload,omitempty"`
	Filter  json.RawMessage `json:"filter,omitempty"`
	IDs     []string        `json:"ids,omitempty"`
	Caller  Caller          `json:"caller"`
}

EntityMutationParams is the shape for host.entity.create / update / delete. For create/update, Payload is the row(s) to write (host-side schema). For delete, Filter/IDs select the target. The host's broker re-dispatches these through the CRUD requireScope chokepoint under the derived permission.

type EntityMutationResult

type EntityMutationResult struct {
	Affected int             `json:"affected,omitempty"`
	Rows     json.RawMessage `json:"rows,omitempty"`
}

EntityMutationResult reports what changed.

type EntityQueryParams

type EntityQueryParams struct {
	Entity string          `json:"entity"`
	Select json.RawMessage `json:"select,omitempty"`
	Filter json.RawMessage `json:"filter,omitempty"`
	Sort   json.RawMessage `json:"sort,omitempty"`
	Limit  int             `json:"limit,omitempty"`
	Offset int             `json:"offset,omitempty"`
	Caller Caller          `json:"caller"`
}

EntityQueryParams is the module→host reverse call host.entity.query. Filter/Sort/Select are json.RawMessage because their shape is the host's existing DSL (framework/filter, framework/dsl) — and this package must NOT import any framework/* leaf. The supervisor's broker parses these with the host's real query machinery on the receive side.

type EntityQueryResult

type EntityQueryResult struct {
	Rows  json.RawMessage `json:"rows"`
	Total int             `json:"total,omitempty"`
}

EntityQueryResult is the result of host.entity.query.

type Error

type Error struct {
	Code    int             `json:"code"`
	Message string          `json:"message"`
	Data    json.RawMessage `json:"data,omitempty"`
}

Error is the JSON-RPC 2.0 error object carried in Frame.Error.

func AsError

func AsError(err error) *Error

AsError unwraps a generic error to a *Error if the underlying value is a wire error (already *Error) or nil otherwise. Convenience for callers that want the structured code/message pair.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Is

func (e *Error) Is(target error) bool

Is allows errors.Is(err, ErrInvalidFrame) etc. to match a wire *Error whose Code corresponds to a known sentinel category. Callers usually want the concrete *Error, so this only bridges the module-specific protocol codes.

type EventEmitParams

type EventEmitParams struct {
	Topic   string          `json:"topic"`
	Payload json.RawMessage `json:"payload"`
	Caller  Caller          `json:"caller"`
}

EventEmitParams is the module→host reverse call host.event.emit. The required permission is derived from <topic>:emit by the broker (design §4.4).

type EventEmitResult

type EventEmitResult struct{}

EventEmitResult is the (currently empty) result of host.event.emit.

type Frame

type Frame struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      *uint64         `json:"id,omitempty"`
	Method  string          `json:"method,omitempty"`
	Params  json.RawMessage `json:"params,omitempty"`
	Result  json.RawMessage `json:"result,omitempty"`
	Error   *Error          `json:"error,omitempty"`
}

Frame is the single bidirectional envelope used by both endpoints of a moduleproto connection (design §4.3). One Frame type carries requests, notifications, and responses — discrimination happens in the read loop, not the type system.

Wire shape (JSON-RPC 2.0):

{
  "jsonrpc": "2.0",            // required; UnmarshalJSON rejects anything else
  "id":     <uint64>,          // present ⇒ request or response; absent ⇒ notification
  "method": "<name>",          // present ⇒ REQUEST (or notification); absent ⇒ RESPONSE
  "params": <raw>,             // requests / notifications only
  "result": <raw>,             // success responses only
  "error":  {code,message,data?}
}

ID semantics are load-bearing:

  • id is a *uint64. nil ⇒ omit the key entirely (notification). A non-nil pointer ⇒ marshal the pointed-to value. This is NOT mcpclient's `uint64 \`json:"id,omitempty"\“ shape: that form re-purposes 0 as the notification sentinel and erases a legitimately-issued id:0. Here id:0 is impossible: the per-direction id counter starts at 1, and UnmarshalJSON rejects a decoded id:0 as malformed.

  • IDs are per-direction (design §4.3): each endpoint owns an independent counter. Host-originated id:7 and child-originated id:7 never collide — see the Peer read loop and peer_test.go's interleaved test.

func NewErrorResponse

func NewErrorResponse(id uint64, code int, message string, data json.RawMessage) *Frame

NewErrorResponse constructs an error response paired to the given id.

func NewNotification

func NewNotification(method string, params json.RawMessage) *Frame

NewNotification constructs a notification Frame (method present, no id).

func NewRequest

func NewRequest(id uint64, method string, params json.RawMessage) *Frame

NewRequest constructs a request Frame with the given id. The id must be > 0; callers normally obtain it from a Peer's monotonic counter.

func NewSuccessResponse

func NewSuccessResponse(id uint64, result json.RawMessage) *Frame

NewSuccessResponse constructs a success response paired to the given id.

func (*Frame) HasID

func (f *Frame) HasID() bool

HasID reports whether the Frame carries an id (i.e. it is a request or a response, not a notification).

func (*Frame) IDValue

func (f *Frame) IDValue() uint64

IDValue returns the id, or 0 if absent. The 0 return for "absent" is NOT a valid id — it is a convenience for callers that have already checked HasID. A decoded Frame never holds id:0 (UnmarshalJSON rejects it), so a 0 return unambiguously means "absent" for any Frame obtained via the codec.

func (*Frame) IsError

func (f *Frame) IsError() bool

IsError reports whether the Frame is an error response.

func (*Frame) IsNotification

func (f *Frame) IsNotification() bool

IsNotification reports whether the Frame is a notification (method present, no id).

func (*Frame) IsRequest

func (f *Frame) IsRequest() bool

IsRequest reports whether the Frame is a request (method present, id present).

func (*Frame) IsResponse

func (f *Frame) IsResponse() bool

IsResponse reports whether the Frame is a response (no method, id present).

func (*Frame) IsSuccess

func (f *Frame) IsSuccess() bool

IsSuccess reports whether the Frame is a successful response (no method, no error).

func (Frame) MarshalJSON

func (f Frame) MarshalJSON() ([]byte, error)

MarshalJSON is the default struct marshal. The struct tags already produce the correct wire shape (id omitted when nil via omitempty on a pointer). It is defined explicitly to lock the shape and so that future tightening (e.g. rejecting a Frame built with method+result) can land here without changing call sites.

func (*Frame) UnmarshalJSON

func (f *Frame) UnmarshalJSON(data []byte) error

UnmarshalJSON validates the wire invariants after default struct decoding. It enforces:

  • jsonrpc must be exactly "2.0".
  • id, if present, must be > 0. id:0 is impossible under this protocol and is rejected to prevent the mcpclient-style zero-sentinel misroute.
  • A frame with a method must NOT also carry result or error (a request that looks like a response is malformed).
  • A frame without a method MUST carry an id and either result or error (responses are paired to a specific request).
  • A frame with neither method nor id is an empty envelope and is rejected.

Discrimination of request-vs-response is intentionally NOT enforced here: the read loop does it, because the same wire bytes are valid for both roles at the type level. The validation here is purely wire-level sanity.

type HTTPMethod

type HTTPMethod string

HTTPMethod is the canonical method on a module.http request. We re-declare a string type rather than importing net/http to keep this package stdlib-only at the type level (the supervisor marshals real http.MethodX values into it).

type HTTPRequestParams

type HTTPRequestParams struct {
	// RequestID is a host-minted correlation id for THIS proxied request.
	// It is independent of the JSON-RPC id (which lives at the envelope
	// level) and is what module.cancel references to abort in-flight work.
	RequestID string `json:"request_id"`
	// RouteID is the installed route's descriptor id.
	RouteID string `json:"route_id"`
	// Method is the HTTP method (GET/POST/...).
	Method HTTPMethod `json:"method"`
	// PathParams are the parsed route path parameters.
	PathParams map[string]string `json:"path_params,omitempty"`
	// Query is the parsed query string parameters.
	Query map[string]string `json:"query,omitempty"`
	// Headers is the allowlist-filtered subset of inbound headers.
	Headers map[string]string `json:"headers,omitempty"`
	// BodyB64 is the raw request body, base64-encoded. Empty string for
	// bodies with no content.
	BodyB64 string `json:"body_b64,omitempty"`
	// Caller carries the resolved end-user context the host delegates to
	// the child for this call.
	Caller Caller `json:"caller"`
	// DeadlineUnixMs is the absolute deadline for this call, in Unix
	// milliseconds. The child must abort by this time regardless of any
	// module.cancel notification.
	DeadlineUnixMs int64 `json:"deadline_unix_ms"`
}

HTTPRequestParams carries a single proxied HTTP request to the module (design §4.4 module.http params). The host is responsible for stripping request body to the allowlisted headers, base64-encoding the body, and attaching the caller's delegation handle.

type HTTPResponseBody

type HTTPResponseBody struct {
	Kind  HTTPResponseBodyKind `json:"kind"`
	Value json.RawMessage      `json:"value"`
}

HTTPResponseBody is the body of a module.http response. The host buffers the entire response before committing any headers (the buffered-503 guarantee, design §8) — no streaming in v1.

type HTTPResponseBodyKind

type HTTPResponseBodyKind string

HTTPResponseBodyKind enumerates the three kinds of body a module.http response may carry (design §4.4). kind:"ui.node.v1" is the closed UI node tree — validated, mapped, rendered, and hydrated by the host (design §9); the module NEVER emits raw HTML/CSS/JS.

const (
	// BodyKindJSON is a JSON-typed response body.
	BodyKindJSON HTTPResponseBodyKind = "json"
	// BodyKindText is a plain-text response body.
	BodyKindText HTTPResponseBodyKind = "text"
	// BodyKindUINodeV1 is a closed ui.node.v1 tree. The host validates it
	// against the allowlist before rendering; see design §9.
	BodyKindUINodeV1 HTTPResponseBodyKind = "ui.node.v1"
)

type HTTPResponseResult

type HTTPResponseResult struct {
	Status  int               `json:"status"`
	Headers map[string]string `json:"headers,omitempty"`
	Body    HTTPResponseBody  `json:"body"`
}

HTTPResponseResult is the result shape for module.http (design §4.4).

type Handler

type Handler func(ctx context.Context, params json.RawMessage) (result any, err error)

Handler is the server-side callback for an inbound request or notification. params is the raw JSON of the Frame.Params field; the handler unmarshals it into the typed shape it expects for its method. Returning a non-nil err causes the Peer to write a JSON-RPC error response with the standard internal-error code; for finer-grained codes, return a *Error.

For notifications (no id) the result is ignored and no response is written; only err is observed (and only for local logging — a notification cannot produce a wire response).

type HandshakeConfig

type HandshakeConfig struct {
	// Expected is the set of values the child MUST echo back identically.
	// The host reads them from the operator-approved descriptor + the live
	// ProcessModuleStore (desired_generation) + its freshly-minted
	// instance_id (§4.7 step 1).
	Expected HandshakeExpected

	// Grants is the effective grant set the host is allowing this child
	// (descriptor.requested ∩ operator.approved).
	Grants []string

	// Limits is the per-child resource envelope the host enforces.
	Limits Limits

	// HostProto is the host's advertised proto range. The negotiated version
	// is min(host.Max, child.Max) if ≥ max(host.Min, child.Min); else
	// terminal. See [Negotiate].
	HostProto ProtoRange

	// HostFeatures is the host's non-critical feature list. The child may
	// ACK a subset; [HandshakeResult.AckedFeatures] is the intersection.
	HostFeatures []string

	// HostCritical is the host's REQUIRED feature list. Every name here
	// MUST appear in the child's echoed features[]; else terminal
	// ([ErrCriticalFeature]).
	HostCritical []string
}

HandshakeConfig is the host-side input to Handshake. Every opaque value here is CALLER-SUPPLIED — this package verifies the round-trip and emits a terminal HandshakeMismatchError on divergence; it makes NO trust decision about whether the expected values are themselves correct. That is the supervisor's policy (design §5 decision B).

type HandshakeExpected

type HandshakeExpected struct {
	// Name is the module's operator-approved name (descriptor-supplied).
	Name string `json:"name"`
	// Version is the module's operator-approved semantic version.
	Version string `json:"version"`
	// ArtifactSHA256 is the SHA-256 of the approved executable, hex-encoded.
	ArtifactSHA256 string `json:"artifact_sha256"`
	// SurfaceSHA256 is the digest of the approved surface descriptor
	// (routes + tool list + requested permissions). Mismatch with the
	// child's echoed value is terminal.
	SurfaceSHA256 string `json:"surface_sha256"`
	// DesiredGeneration is the monotonic desired-state counter persisted in
	// ProcessModuleStore. It is READ at spawn (not minted here); the child
	// echoes it back in identity. A mismatch means the child is stale.
	DesiredGeneration uint64 `json:"desired_generation"`
	// InstanceID is the random per-spawn liveness nonce the host minted for
	// THIS spawn (§4.7 step 1). Rejects stale/duplicate connections from a
	// prior spawn. Never persisted, never monotonic.
	InstanceID string `json:"instance_id"`
}

HandshakeExpected carries the caller-supplied values the host expects the child to round-trip in module.handshake (design §4.7 steps 1-3). Every field here is an OPAQUE caller-supplied value — this package verifies the round-trip and emits a terminal HandshakeMismatchError on divergence; it does NOT decide whether the expected values are themselves trustworthy (that is the supervisor's policy — design §5 decision B).

type HandshakeMismatchError

type HandshakeMismatchError struct {
	Field string // human-readable name of the mismatched field
	Want  string // caller-supplied expected value
	Got   string // child-echoed value
}

HandshakeMismatchError is returned by Handshake when the child's echoed identity or digests do not round-trip the caller-supplied expected values, or when proto negotiation fails. It is terminal per design §4.5/§4.7.

func (*HandshakeMismatchError) Error

func (e *HandshakeMismatchError) Error() string

type HandshakeOutcome

type HandshakeOutcome struct {
	// NegotiatedProto is the single integer version both peers MUST use.
	NegotiatedProto int

	// Identity is the child's echoed identity, verified to round-trip the
	// host's [HandshakeConfig.Expected].
	Identity Identity

	// SurfaceSHA256 is the child's echoed surface digest, verified equal
	// to the host's expected value.
	SurfaceSHA256 string

	// Ready is the child's initial ready flag. The host polls [MethodReady]
	// separately to gate on warmup completion; Ready here is informational.
	Ready bool

	// AckedFeatures is the intersection of the host's HostFeatures and the
	// child's echoed features[].
	AckedFeatures []string
}

HandshakeOutcome is the result of a successful Handshake: the negotiated proto version, the cross-checked identity, and the agreed feature set.

func Handshake

func Handshake(ctx context.Context, p *Peer, cfg HandshakeConfig) (*HandshakeOutcome, error)

Handshake performs design §4.7 steps 3-4: it issues module.handshake with the caller-supplied config, then validates the response by

  1. cross-checking the child's echoed identity + surface digest against the expected values (terminal HandshakeMismatchError on divergence);
  2. negotiating the proto version (terminal ErrNegotiation on empty intersection / below-floor);
  3. checking every host-critical feature appears in the child's features (terminal ErrCriticalFeature on miss).

Handshake does NOT poll module.ready — that is a separate step (§4.7 step 4). The Ready field on the outcome is the child's initial flag; the supervisor drives the warmup poll via MethodReady.

type HandshakeParams

type HandshakeParams struct {
	Expected HandshakeExpected `json:"expected"`
	Grants   []string          `json:"grants"`
	Limits   Limits            `json:"limits"`
	Features []string          `json:"features,omitempty"`
	Critical []string          `json:"critical,omitempty"`
	Proto    ProtoRange        `json:"proto"`
}

HandshakeParams is the params shape for module.handshake (design §4.4). The host is the only legitimate originator; the child cannot alter these.

type HandshakeResult

type HandshakeResult struct {
	// Proto is the child's advertised proto range. The negotiated version
	// is computed by the host via [Negotiate] and returned alongside.
	Proto ProtoRange `json:"proto"`
	// Identity is the child's echoed identity — round-trips the host's
	// [HandshakeExpected] values.
	Identity Identity `json:"identity"`
	// SurfaceSHA256 is the child's view of its surface digest; the host
	// compares it to [HandshakeExpected].SurfaceSHA256.
	SurfaceSHA256 string `json:"surface_sha256"`
	// Features is the feature set the child ACKs.
	Features []string `json:"features"`
	// Ready is the child's initial ready state (typically false; the host
	// polls module.ready separately to gate on warmup completion).
	Ready bool `json:"ready"`
}

HandshakeResult is the result shape for module.handshake (design §4.4).

type HealthParams

type HealthParams struct{}

HealthParams is empty — module.health takes no params.

type HealthResult

type HealthResult struct {
	OK       bool `json:"ok"`
	Inflight int  `json:"inflight"`
}

HealthResult is the result of module.health. ok:false means the child is alive but unhealthy; inflight reports current in-flight request count.

type Identity

type Identity struct {
	Name              string `json:"name"`
	Version           string `json:"version"`
	InstanceID        string `json:"instance_id"`
	DesiredGeneration uint64 `json:"desired_generation"`
}

Identity is the child's echoed identity in module.handshake's result. The host compares every field against HandshakeExpected — instance_id and desired_generation MUST round-trip exactly (they are the spawn-freshness and generation-staleness anchors; §4.4 vocabulary note).

type Limits

type Limits struct {
	// FrameBytes is the negotiated max_frame_bytes. Default 1 MiB
	// (see [DefaultMaxFrameBytes]). Must be ≤ the codec's structural
	// scanner cap (4 MiB); the codec rejects a larger negotiated value at
	// construction.
	FrameBytes int `json:"frame_bytes"`
	// DeadlineMs is the per-call deadline ceiling for module.* requests.
	// The descriptor may lower it; never raise. Default 10 000 (10 s).
	DeadlineMs int `json:"deadline_ms"`
	// Inflight is the maximum simultaneously in-flight module.* requests
	// the host will issue. Default 32. Over-cap is a clean call-local
	// error, not a protocol fault.
	Inflight int `json:"inflight"`
}

Limits is the per-child resource envelope the host sets at handshake (design §4.4 module.handshake params). All zeros means "host defaults apply."

type OvercapError

type OvercapError struct {
	Size int // bytes observed; -1 if unknown (e.g. bufio.Scanner ErrTooLong)
	Cap  int // negotiated cap in bytes
}

OvercapError is returned by the codec when a frame exceeds the negotiated max_frame_bytes on read or write. It is terminal: by design §4.2 the peer never truncates, so the connection is unusable.

func (*OvercapError) Error

func (e *OvercapError) Error() string

func (*OvercapError) Is

func (e *OvercapError) Is(target error) bool

Is makes OvercapError comparable for sentinel checks.

type Peer

type Peer struct {
	// contains filtered or unexported fields
}

Peer is a full-duplex endpoint over a Codec. Both the host and the child construct one; each Peer serves inbound requests AND originates outbound requests concurrently over the SAME connection. This is the load-bearing break from mcpclient, which could originate but not serve.

Per-direction ID correlation (design §4.3 — the most-tested property):

  • Each Peer owns an independent id counter ([Peer.nextID], atomic, starts at 0; the first Peer.Call uses id=1).
  • Each Peer owns its own pending map.
  • The read loop consults its local pending map ONLY for frames that are responses (method == "" && id present). A request with the same numeric id is handled by the request branch, which echoes the id back without touching the map.

Therefore host-originated id:7 and child-originated id:7 NEVER collide. See peer_test.go's interleaved bidirectional test, which pins this property.

Cancellation (design §4.4 module.cancel):

  • The Peer installs a built-in handler for MethodCancel. When a module.cancel notification arrives with a given request_id, the Peer cancels the context of the inbound request currently serving that id (the request_id is the inbound frame's id, encoded as a string). The child's handlers observe ctx.Done and abort.
  • Origination-side cancellation (the host sending module.cancel when ITS Call's ctx expires) is policy, NOT codec behavior. The supervisor wires it by calling Peer.Notify from a goroutine that watches ctx.Done.

func NewPeer

func NewPeer(codec *Codec, role Role, opts ...PeerOption) *Peer

NewPeer constructs a Peer over codec with the given role. The Peer does NOT start its read loop until Peer.Start is called.

func (*Peer) Call

func (p *Peer) Call(ctx context.Context, method string, params any) (json.RawMessage, error)

Call originates a request with the given method and params, waits for the paired response, and returns the result's raw bytes. params may be nil (no params field), a typed value (JSON-marshaled), or json.RawMessage (passed through). The id is drawn from this Peer's monotonic counter — the first Call uses id=1, the next id=2, etc.

Call is safe for concurrent use by multiple goroutines.

Failure modes:

  • ctx expired: the pending entry is removed and ctx.Err() is returned. Call does NOT automatically emit module.cancel — that is supervisor policy. Origination-side cancellation is a Peer.Notify away.
  • inflight cap reached: returns ErrInflightCap without writing anything.
  • Peer closed (or read loop exited): returns ErrClosed (or the terminal Peer.FatalError if one was observed).
  • the child returned a JSON-RPC error: returns it as a *Error.
  • codec write failed (e.g. over-cap): returns that terminal error.

func (*Peer) Close

func (p *Peer) Close() error

Close shuts the peer down. It is idempotent. After Close returns, Call and Notify return ErrClosed. The Peer does NOT own its Codec's underlying io.Closer (the supervisor wires os.Stdin / a net.Conn / etc.); closing the transport is the supervisor's job. Close signals the read loop to exit on the next read (which will return EOF or a closed-pipe error).

To actually tear down a child, the supervisor: (1) closes the write side so the child observes EOF and exits cleanly; (2) after a deadline, kills the process; (3) waits. That sequence is the design's Close = stdin.Close → Kill → Wait (§4.6 lift list) and lives above this package.

func (*Peer) Done

func (p *Peer) Done() <-chan struct{}

Done returns a channel closed when the read loop has exited (clean EOF, fatal codec error, or Peer.Close). Callers waiting on an in-flight Call should also select on Done.

func (*Peer) FatalDone

func (p *Peer) FatalDone() <-chan struct{}

FatalDone returns a channel closed when the first terminal protocol fault is observed. The supervisor observes FatalDone, calls FatalError to retrieve the cause, then tears down the transport (which lets the read loop exit and Done fire). In-flight Calls also select on FatalDone and return the fatal error promptly without waiting for transport teardown.

func (*Peer) FatalError

func (p *Peer) FatalError() error

FatalError returns the first terminal error observed by the read loop, or nil if the loop ended cleanly (EOF or Close).

func (*Peer) Handle

func (p *Peer) Handle(method string, h Handler) error

Handle registers (or replaces) a handler for an inbound method. Registering MethodCancel is refused — that handler is built-in. Registering a handler after Peer.Start is allowed (the read loop consults the map under the mutex), but doing so concurrently with traffic is unusual.

func (*Peer) Notify

func (p *Peer) Notify(ctx context.Context, method string, params any) error

Notify sends a notification (no id, no response expected) with the given method and params. notifications are one-way; the peer does not acknowledge.

MethodCancel is special: the Peer has a built-in handler for it on the receive side, but the origination side is the supervisor's policy (only the host sends module.cancel). Notify itself places no role restriction — the policy lives above the codec.

func (*Peer) Role

func (p *Peer) Role() Role

Role returns the Peer's role.

func (*Peer) Start

func (p *Peer) Start()

Start launches the read loop. It panics if called twice (the read loop must be single-threaded — see Codec).

type PeerOption

type PeerOption func(*Peer)

PeerOption configures a Peer at construction.

func WithHandler

func WithHandler(method string, h Handler) PeerOption

WithHandler registers a handler at construction. Repeat to register more.

func WithMaxInflight

func WithMaxInflight(n int) PeerOption

WithMaxInflight overrides the default in-flight cap (DefaultMaxInflight) for originated requests.

func WithMaxServeInflight

func WithMaxServeInflight(n int) PeerOption

WithMaxServeInflight overrides the default cap (DefaultMaxInflight) on concurrently SERVED inbound requests. This bound is what protects a peer from a flooding counterparty: the origination-side cap only limits requests a peer sends, so a hostile peer that never awaits responses could otherwise spawn one handler goroutine per frame it writes. Overflow requests are answered immediately with a CodeInflightCap error response — never silently dropped (a drop would hang the caller) and never queued.

type ProtoRange

type ProtoRange struct {
	Min int `json:"min"`
	Max int `json:"max"`
}

ProtoRange is the integer protocol-version window a peer advertises in module.handshake (design §4.5). Versions are plain integers — there is no MCP-style "YYYY-MM-DD" string and no semver. [Min, Max] is inclusive.

A peer that speaks exactly one version sets Min == Max. The floor for v1 of this package is [ProtoV1Min, ProtoV1Max].

func (ProtoRange) Contains

func (r ProtoRange) Contains(v int) bool

Contains reports whether v falls in [r.Min, r.Max] inclusive.

func (ProtoRange) Validate

func (r ProtoRange) Validate() error

Validate enforces the structural invariant Min ≤ Max. A malformed range is treated as a terminal handshake fault by Negotiate.

type ReadyParams

type ReadyParams struct{}

ReadyParams is empty — module.ready takes no params (design §4.4).

type ReadyResult

type ReadyResult struct {
	Ready  bool   `json:"ready"`
	Detail string `json:"detail,omitempty"`
}

ReadyResult is the result of module.ready. ready:true means the child has completed warmup and may serve proxied traffic.

type RingSink

type RingSink struct {
	// contains filtered or unexported fields
}

RingSink is a bounded, non-blocking byte sink that retains the last N bytes written to it (design §4.2). It wraps the child's stderr so the host can observe recent child log output without allowing an unbounded log to exhaust memory. It replaces mcpclient's drainStderr's `io.Copy(io.Discard, …)`.

Properties:

  • NEVER blocks the writer (the child's stderr drain goroutine). Write always succeeds immediately and returns len(p), regardless of how full the ring is.
  • Retains at most cap bytes. When the ring would overflow, the OLDEST bytes are evicted. [Tail] returns the retained slice in write order.
  • Safe for concurrent Write and Tail.

Implementation: a single growable []byte guarded by a mutex, trimmed to the last cap bytes on each write. The trim is O(remaining) but stderr is low-volume relative to the protocol path, so a proper circular buffer would add complexity without measurable benefit. If the stderr path ever carries high volume, swap in a circular buffer keeping the same API.

func NewRingSink

func NewRingSink(capBytes int) *RingSink

NewRingSink constructs a RingSink retaining the last capBytes bytes. If capBytes <= 0, DefaultRingSinkBytes is used.

func (*RingSink) Cap

func (r *RingSink) Cap() int

Cap returns the configured capacity in bytes.

func (*RingSink) Drain

func (r *RingSink) Drain(src io.Reader) error

Drain copies everything from src into the sink until src returns EOF (or a non-EOF read error, which is returned). It is intended to run in its own goroutine wrapping the child's stderr; it blocks on src.Read, NEVER on the ring (Write never blocks).

On a non-EOF error from src, Drain returns that error so the caller can log it; the sink retains whatever was copied up to that point.

func (*RingSink) Len

func (r *RingSink) Len() int

Len returns the number of bytes currently retained.

func (*RingSink) Reset

func (r *RingSink) Reset()

Reset clears the retained bytes.

func (*RingSink) Tail

func (r *RingSink) Tail() []byte

Tail returns a snapshot of the retained bytes in write order. The returned slice is a copy; the caller may mutate it freely.

func (*RingSink) Write

func (r *RingSink) Write(p []byte) (int, error)

Write appends p to the ring, evicting the oldest bytes if necessary so the retained slice never exceeds cap. It never blocks and never returns an error. Implements io.Writer.

type Role

type Role int

diagnostic/policy only: the codec and Frame type are symmetric, and a Peer of either role can both originate and serve requests. The supervisor uses Role to decide policy (e.g. only the host role sends module.cancel; the child role's handlers are subject to it).

const (
	// RoleHost is the host-side endpoint: originates module.* requests and
	// serves host.* reverse requests.
	RoleHost Role = iota
	// RoleChild is the module-side endpoint: serves module.* requests and
	// originates host.* reverse requests.
	RoleChild
)

func (Role) String

func (r Role) String() string

String makes Role log-friendly.

type SearchQueryParams

type SearchQueryParams struct {
	Query  string          `json:"query"`
	Filter json.RawMessage `json:"filter,omitempty"`
	Limit  int             `json:"limit,omitempty"`
	Caller Caller          `json:"caller"`
}

SearchQueryParams is the module→host reverse call host.search.query.

type SearchQueryResult

type SearchQueryResult struct {
	Results json.RawMessage `json:"results"`
	Total   int             `json:"total,omitempty"`
}

SearchQueryResult is the result of host.search.query.

type Tool

type Tool struct {
	ID          string          `json:"id"`
	Name        string          `json:"name"`
	Description string          `json:"description,omitempty"`
	InputSchema json.RawMessage `json:"input_schema,omitempty"`
}

Tool is one entry in the module.tool.list result. The host registers these into its existing core/mcp.Server under a per-module prefix (design §5.1). At handshake the host verifies byte-equality with the descriptor's tool digests — the child cannot add, rename, or reshape at runtime.

type ToolCallParams

type ToolCallParams struct {
	ToolID         string          `json:"tool_id"`
	Arguments      json.RawMessage `json:"arguments,omitempty"`
	Caller         Caller          `json:"caller"`
	DeadlineUnixMs int64           `json:"deadline_unix_ms"`
}

ToolCallParams invokes one tool by id (design §4.4 module.tool.call).

type ToolCallResult

type ToolCallResult struct {
	Result json.RawMessage `json:"result"`
}

ToolCallResult is the result of module.tool.call. The host re-validates any ui.node.v1 content in Result exactly as it would for module.http.

type ToolListResult

type ToolListResult struct {
	Tools []Tool `json:"tools"`
}

ToolListResult is the result of module.tool.list.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL