Documentation
¶
Overview ¶
Package proxy is the behalf MCP stdio interposer — the canonical v1 capture surface and reference implementation (D4, Q44).
It sits between an MCP client and a real MCP server over stdio and forwards newline-delimited JSON-RPC in both directions VERBATIM, with one exception: on client->server `tools/call` requests it splices the two legal keys into `params._meta` (the chain under `sh.behalf/chain`, W3C trace context under `baggage` — Q15, Q50, D4). Nothing else is reordered, rewritten or re-serialized; a JSON diff of any forwarded line against the line that arrived differs only inside `params._meta`, and only on tools/call.
MCP revision 2026-07-28 is stateless over stdio, so there is no initialize session to track: each line stands alone and responses are matched to requests by JSON-RPC id.
What gets recorded ¶
Q2's closed rule: every `tools/call` through the proxy is a receipt, reads included. Server->client requests, notifications, unmatched ids and all other traffic cross byte-verbatim and produce no receipts.
Per call, in this order (Q4, Q48):
- allocate the per-emitter monotonic counter;
- write the raw params bytes into the customer-held CAS;
- durably spool the INTENT (fsync) — before anything is forwarded;
- forward the request;
- on the matching response, build the completion receipt, sign it with the emitter key (DSSE/PAE) and durably spool it;
- forward the response.
The proxy never appends to the log: one appender per log (Q57). A drain moves the spool into the log at-least-once, safe because ingest dedups on receipt_id (Q46). A crash between steps 3 and 5 leaves an intent with no completion, and the next Open flushes it as an `orphan_intent` receipt carrying the spooled intent digest (Q4, Q5).
Failure posture ¶
A capture failure — spool, CAS or signing — aborts the proxy rather than forwarding an unrecorded call. behalf's own premise is that a silent gap is indistinguishable from tampering (Q45), so a recorder that cannot record must not pretend to. This is stricter than Q47's observe-mode default, which concerns log backpressure, not a broken capture surface.
Verification at capture ¶
The proxy verifies the chain it forwards (Q18): the D5 root predicate at depth 0, the AAT signature chain and its invariants above it, and the attenuation comparison over the raw RFC 9396 grants — all offline, all in customer territory, all in internal/aat. The per-hop `{status, method, evidence_ref}` in the receipt is that result; the receipt-level rollup is the weakest hop (Q12). A carried hop's own claim about its verification status is discarded on the way in.
The chain and the login material are both fixed for the life of the process, so verification runs once at startup rather than per receipt.
Not in Week 3 ¶
This is observe mode only: the proxy records what it checked, and does not enforce leaf scope before forwarding (Q47's opt-in enforcement mode). A chain that verifies as `broken` is recorded as broken and forwarded, per Q45 — append and flag, never gate.
Index ¶
Constants ¶
const ( // MetaKeyChain carries the AAT chain material (D4, Q15). MetaKeyChain = "sh.behalf/chain" // MetaKeyBaggage carries W3C baggage (D4, Q50). MetaKeyBaggage = "baggage" // BaggageRunKey is the baggage member the proxy contributes. BaggageRunKey = "behalf-run-id" )
The one legal injection. MCP revision 2026-07-28 reserves `params._meta` for out-of-band metadata under vendor-prefixed keys whose second label is not "mcp", which is exactly what D4/Q15 rely on: the delegation chain travels beside the request under `sh.behalf/chain`, and W3C trace context travels under `baggage`. Verification comes from the chain's own signatures, so out-of-band carriage costs nothing (Q15).
const ( // EnvRunID is the caller/SDK-supplied key — the top rung. EnvRunID = "BEHALF_RUN_ID" // EnvTraceparent is the W3C traceparent the caller exported; its // trace-id field is the root trace_id (Q7, Q50). EnvTraceparent = "TRACEPARENT" )
run_id is populated by normative precedence, and every receipt records which rung fired so grouping is honest about its own provenance (Q7, receipt-schema-v1.md §6 and §9 item 7). The proxy can reach three of the four rungs; `hook-session` belongs to the Claude Code hook surface (D4).
const ( ProvenanceCaller = "caller" ProvenanceTraceparent = "traceparent" ProvenanceProxySession = "proxy-session" )
Provenance values, matching the schema's run_id_provenance enum.
const CarriageRouteMeta = "mcp-_meta:" + MetaKeyChain
CarriageRouteMeta records that a hop arrived beside the request in params._meta rather than in band (Q15, D4).
const DefaultPolicyJSON = `` /* 374-byte string literal not displayed */
DefaultPolicyJSON is the built-in policy used when no --policy file is given. It is a real config, digested like any other, so a receipt written without a policy file still says exactly what classified it.
const DefaultSpoolDirName = "proxy-spool"
DefaultSpoolDirName is the capture spool under the state directory.
const KindOrphanIntent = "orphan_intent"
KindOrphanIntent is the record kind recovery flushes (Q4, Q5).
const KindToolCall = "tool_call"
KindToolCall is the record kind for a tools/call crossing (Q6).
const MethodToolsCall = "tools/call"
MethodToolsCall is the one method the proxy treats as a trust-boundary crossing (Q2's closed rule: every MCP tools/call through the proxy is a receipt). Everything else crosses byte-verbatim.
const OtelConventionsVersion = "1.29.0"
OtelConventionsVersion is the gen_ai.* semantic-conventions version in force at capture, stamped per record so old receipts can be re-normalised when the still-Development conventions move (Q8, Q49).
const Surface = "mcp-proxy"
Surface is the emitter.surface value for this capture surface — the canonical v1 surface (Q44, D4).
Variables ¶
This section is empty.
Functions ¶
func FixedClock ¶
FixedClock returns a clock that starts at t and advances by step on every read. A recorder wants a clock that moves — receipts carry captured_at, and a run whose 47 steps share one timestamp reads as a lie about what happened — but moves by a fixed amount, so the same script produces the same timeline every time.
A zero step returns a frozen clock, which is what a test that only cares about ordering wants.
func FixedEntropy ¶
FixedEntropy returns a reproducible entropy stream seeded by seed — SHA-256 in counter mode, which is deterministic, endless, and has no short cycles the way a small LCG would.
Seed it with something run-scoped. Two runs recorded with the same seed and the same clock mint the same receipt_ids, and the log dedups on receipt_id (Q46): the second run would be swallowed as duplicates of the first. Seeding per run id is what keeps two recordings of the same script distinguishable while keeping each one reproducible.
func RecoverOrphans ¶
RecoverOrphans mints, signs and spools an `orphan_intent` receipt for every intent left unmatched in the spool at dir. It is what the drain calls; the proxy runs the same path at startup. Returns how many were flushed.
Types ¶
type Chain ¶
type Chain struct {
Raw []byte // compacted JSON, injected verbatim into _meta
Hops []aat.Hop // parsed hops, verified at capture and embedded whole (Q10, Q18)
}
Chain is loaded chain material.
func LoadChain ¶
LoadChain reads chain material. An empty path returns nil: absent chain means no injection and no authority block.
func ParseChain ¶
ParseChain compacts and parses chain material bytes.
type Config ¶
type Config struct {
// StateDir is the resolved behalf state directory (identity.ResolveDir).
// It holds the emitter key, the monotonic counter and, by default, the
// spool and the CAS. Required.
StateDir string
// SpoolDir defaults to <StateDir>/proxy-spool.
SpoolDir string
// CASDir defaults to <StateDir>/blobs — the customer-held payload store.
CASDir string
// PolicyPath is the tool-policy config; empty uses DefaultPolicyJSON.
PolicyPath string
// ChainPath is the chain material; empty means no injection and
// `unattributed` receipts.
ChainPath string
// Command is the real MCP server command and its arguments. Required.
Command []string
// Env is the server's environment; nil inherits the proxy's.
Env []string
// Getenv resolves the run_id precedence rungs; nil uses os.Getenv.
Getenv func(string) string
// Now overrides the clock; nil uses time.Now. Set by tests and by
// cmd/behalf-record's deterministic recording mode (see
// deterministic.go); production leaves it nil.
Now func() time.Time
// Entropy overrides the ULID entropy source that mints receipt_id and
// intent_id; nil uses crypto/rand. Set by deterministic recordings, and
// only ever alongside Now — the two together are what make a recording
// byte-reproducible (see deterministic.go).
Entropy io.Reader
}
Config configures Run.
type Policy ¶
type Policy struct {
Version string `json:"version"`
Default string `json:"default"`
Rules []Rule `json:"rules"`
// contains filtered or unexported fields
}
Policy is a loaded tool-policy config plus the digest of the exact bytes it was loaded from.
func LoadPolicy ¶
LoadPolicy reads a tool-policy config. An empty path loads DefaultPolicyJSON.
func (*Policy) Classify ¶
Classify returns the risk class for a tool name and the argument name (if any) that supplies operation.target.
func (*Policy) Digest ¶
Digest is the sha256 of the config bytes, recorded on every receipt the policy classifies (Q6).
func (*Policy) OutcomeFields ¶
OutcomeFields returns the result fields the matching rule records in operation.outcome, or nil when the tool has no rule or the rule names none.
type Rule ¶
type Rule struct {
Pattern string `json:"pattern"`
Class string `json:"class"`
TargetArg string `json:"target_arg,omitempty"`
// OutcomeFields names the top-level scalars of the tool's structured
// result that are recorded in operation.outcome.
OutcomeFields []string `json:"outcome_fields,omitempty"`
}
Rule maps a tool-name glob to a risk class.