stretto-proxy
A stdio MCP proxy that records what an agent does with a server's tools, and can act on it: run a stretto flow behind the agent's calls, check its writes against policy guards, and add a commit tool.
stretto-proxy starts the real MCP server as a child process, or connects to it over Streamable HTTP (--upstream), and sits between it and the MCP host (a desktop app, an IDE, an agent framework). The host always runs the proxy as a stdio server. It forwards every line in both directions byte for byte and, with --record, also writes each line to a session log. stretto_trace::mcp turns logs into stretto's canonical Episode, so recorded sessions feed the same models as τ²-bench trajectories.
Usage
stretto-proxy [--record <DIR>] [--domain <NAME>] [--agent-model <MODEL>]
[--flow <FILE> [--oracle jev|replay|mock] [--oracle-cache <DIR>] [--flow-decider arbiter|habit|reach] ...]
[--guards [--confirm-judge log|enforce [--confirm-second proposed] ...]]
[--commit] [--context <FILE>] -- <SERVER_COMMAND>...
stretto-proxy [the same options] --upstream <URL> [--upstream-header NAME=VAR]...Install it with cargo install --path crates/stretto-proxy, which also installs stretto-mcp-demo and stretto-procedure, which runs a compiled procedure against an MCP server with no model (formats, CLI). In the host's configuration, replace the server's command with stretto-proxy and put the original command after --:
{
"mcpServers": {
"orders": {
"command": "stretto-proxy",
"args": ["--record", "~/.stretto/logs", "--domain", "orders", "--", "npx", "-y", "some-mcp-server"],
"env": { "SOME_API_KEY": "…" }
}
}
}--record DIRwrites one log per session toDIR/<session>.jsonl, creatingDIRif needed. A leading~is expanded, because hosts start servers without a shell. Hosts start servers in a directory of their choosing, so use an absolute path (or~).--domainand--agent-modelare copied into the log header. The proxy cannot see which model drives the agent, so name it here if you know it.- Without
--record, the proxy only forwards.
Streamable HTTP servers. --upstream https://example.com/mcp proxies a server that speaks MCP's HTTP transport (revision 2025-06-18), in place of a command:
"args": ["--record", "~/.stretto/logs", "--domain", "orders",
"--upstream", "https://example.com/mcp", "--upstream-header", "Authorization=ORDERS_AUTH"],
"env": { "ORDERS_AUTH": "Bearer …" }- Each message the host sends is POSTed to the endpoint. The server's answer comes back to the host as lines, whether it is one JSON body or an event stream: progress notifications, log messages, then the result. The session id the server assigns (
Mcp-Session-Id) and the agreed protocol version (MCP-Protocol-Version) go with every later request. - After
notifications/initialized, a GET stream carries the messages the server sends on its own; it is opened again if it drops. When the host closes the proxy's stdin, the proxy waits for the requests in flight and ends the session with a DELETE. --upstream-header NAME=VARsends headerNAMEwith the value of environment variableVAR, such as a bearer token, on every request. Values are never logged. The log header records the URL without a user, a password, or the values of query parameters that look like credentials.- A request the server refuses, or cannot be reached for, is answered with a JSON-RPC error, so the host is not left waiting. The proxy exits with 0 once the session has ended.
- Recording, flows, guards and
stretto_commitwork as they do with a command. CI runs the proxy in front of the reference server,@modelcontextprotocol/server-everything(scripts/http_check.py), and the tests run it in front ofstretto-mcp-demo --http, which insists on the session id and version.
The server inherits the proxy's environment and stderr. The proxy's stdout carries only the protocol. Its own messages go to stderr, prefixed stretto-proxy:; with --record, the first one says where the log is.
When the host closes the proxy's stdin, the proxy closes the server's stdin, forwards whatever the server still writes, waits for it to exit, and exits with its status (128 + n if signal n killed it). If the server exits first, the proxy forwards the rest of its output and exits the same way. If the proxy itself fails (the log cannot be created, the server cannot be started), it exits with 125 and leaves no log behind. Usage errors exit with 2.
Try it
stretto-mcp-demo is a tiny MCP server for trying the proxy. It has two tools that store nothing and answer with their arguments: lookup, annotated read-only, and update, annotated as not read-only. "fail": true makes either return an error result, and "delay_ms": n makes it wait before answering, to try parallel calls.
cargo build -p stretto-proxy
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"shell","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup","arguments":{"text":"hello"}}}' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"update","arguments":{"text":"hi","fail":true}}}' \
| target/debug/stretto-proxy --record /tmp/stretto-logs -- target/debug/stretto-mcp-demo
cat /tmp/stretto-logs/*.jsonlTo try it in a host, configure stretto-proxy as above with stretto-mcp-demo after --.
stretto-mcp-demo --world retail serves a tiny shop instead, with four of τ²-bench retail's tool names and canned data (cN@example.com is user_N, whose orders #WNa and #WNb are pending), for trying active mode. The walkthrough runs the proxy on a real server, the official MCP filesystem server.
Active mode
With any of --flow, --guards, --commit or --context, the proxy reads what crosses it, on one thread, and acts on it. Everything it does not act on is still forwarded byte for byte.
--flow FILEruns a flow after each of the agent's calls (RFC-001's arm D0). The file is a flow IR fromstretto compile(τ²-bench results) orstretto learn(sessions this proxy recorded). When the server answers one of the agent'stools/calls, the proxy holds the response and asks the flow what comes next. While the flow proposes lookups, the proxy makes them itself, as requests with idsstretto-<n>. When the flow hands back, the agent gets its own result with one more text item, in the pilot's format:text--- Also looked up automatically (current results; no need to repeat these calls) --- get_user_details {"user_id":"user_7"}: {"user_id":"user_7","orders":["#W7a","#W7b"],…}A flow only calls tools it reads as lookups and that the server, once it has listed its tools, does not mark
readOnlyHint: false. A flow learned from recorded sessions pins each tool's input contract (its arguments, their types and which are required), and the proxy makes no lookup of a tool whose server now lists another: the flow learned to bind arguments that are not there any more, so the agent makes that call itself.--flow-toolsnames the only tools the flow may call on its own: a server'sreadOnlyHintsays a call changes nothing, not that it is free, unlogged, or fine to make unasked, since a read can be metered, rate-limited, or recorded as an access. One flow runs at a time; a response that arrives meanwhile is forwarded as it is.--oraclesays who answers the flow's questions:jev(the default; needsTYPESAFE_API_KEY, orTYPESAFE_API_KEY_FILEnaming a file that holds it),replay(the cache only) ormock. Answers are cached in--oracle-cache(default~/.stretto/oracle-cache), keyed by the request's hash.--flow-threshold(0.3) is the probability the lookup must reach: the tool's, times how often its arguments' binding matched the agent in training.--flow-decidersays where the tool's probability comes from:arbiter(the habit, the System-One model's answers and the predicates, combined),reach(how often the agent made the lookup before its next write, counted in training) orhabit(how often it made it next). By default a flow is served with its arbiter, and a flow without one, such as every flowlearn --habit-onlywrites, withreach.reachandhabitask no one, so they need no key and add no latency. Replayed on GLM-5's and Claude 3.7 Sonnet's recorded test episodes, the habit alone saved as many turns as the arbiter, with more detours, mostly in airline (results);reachis the better calibrated of the two (results).--flow-per-call(8),--flow-per-session(40) and--flow-questions(300) cap lookups per result, lookups per session and questions per session.Each decision is appended to
--flow-log, by default<session>.flow.jsonlnext to the session log, with its site in the flow's run (address, such asdecide#0). The run is the fugue program the flow holds (formats).--flow-explore EPSILONexplores (RFC-001 §3.7): with that probability, the flow takes a lookup other than the rule's choice, drawn by the decider's probabilities among the lookups that bind. Each decision then logs itspolicy: every lookup the site offered (the decider's and the habit's probabilities, the binding's chance and arguments), the rule's own choice (greedy), and the chance that the flow took what it took (propensity).stretto evaluatereads those, labelled, to estimate what another rule would have done.--flow-explore 0explores nothing and still logs them;--flow-explore-seedseeds the draws.After each run's decisions comes one line for the run itself,
run:- the program's data:
call,failedandmax_lookups; - each site as
[address, value, logp]; surprise: how unexpected the server's answers were under the flow's statistics, in nats.
Given the data, the flow's program scores the run again with fugue's
ScoreGivenTrace.- the program's data:
--flow-shadowruns the flow in shadow mode (RFC-001 §3.7): it decides after each call and logs what it would look up, with"shadow": true, but makes no lookups, so the agent gets the server's results unchanged.stretto promote --sessionsthen makes the same decisions again from the answers the proxy cached (pass its--oracle-cache), scores them against what the agent did, and writes a flow that acts only where its lookups were the agent's own (promotion).A promoted flow hands back after a call whose site was not promoted, with the reason
the site is not promoted.--task-idpicks the flow's fold; by default it is the session.
--guardschecks each of the agent's calls against the policy guards of--domain(retailorairline) before the server sees it. A call an enforced rule refuses never reaches the server. The agent gets an error result instead:Refused by the policy check, so cancel_pending_order was not run and nothing changed: 'found it cheaper' is not an accepted reason (policy check retail.cancel_reason).A rule that lacks the facts to decide does not refuse.stretto guardstests the rules against recorded trajectories.--confirm-judge log|enforce(with--guardsand--context) puts each write the guards check for a confirmation (the ruleretail.confirmedorairline.confirmed) to the System-One model too, with the questionstretto confirmasks: did the customer's reply explicitly agree to this exact change? The judge sees what the agent said last before the customer's last message, that message and the call, built exactly asstretto confirmbuilds them, so the two share cached answers. The guards' word list for that rule is only logged, since it cannot tell a confirmation from a request; the judge is meant to replace it.logrecords each judgment and refuses nothing the guards would not.enforcealso refuses a write whose answer puts the probability of a yes below--confirm-threshold(0.5):Refused by the policy check, so cancel_pending_order was not run and nothing changed: the customer has not explicitly agreed to this exact change (confirmation judge: p_yes 0.15); describe the change and ask the customer to confirm it first.- A judge that cannot answer refuses nothing: no key, a cache miss under
--oracle replay, an error, or the session's--confirm-questions(100) spent. --confirm-second proposedalso asks the second question: had the agent proposed this change? A write then fails unless both answers are yes.describedasks that page's first wording, which is too literal. With--confirm-second-shadow, the second answer is only logged ("second_shadow": true), and a write fails on the first answer alone.--oracleand--oracle-cacheare the ones--flowuses.- Each judgment is appended to
--confirm-log, by default<session>.confirm.jsonlnext to the session log: the call, the word list's verdict, each answer, whether the write failed and whether that was enforced, the questions' cache keys, and how long the write waited for the judge. Beside the judge, and logged only,proposal_checklists the call's values whose record the confirmation chose another of, with no model: another order, item or card of the same list than the one the customer's reply, or the agent's proposal, named (the check). - Offline, enforcing the first question would refuse 5–11% of the writes accepted in τ²-bench's successful episodes, most of them real lapses (results). Live, enforced on 20 episodes, it refused nothing; logged on 20 more, it would have stopped 2 real lapses for 2 false alarms, and passes did not move. The second question's flags were 7 false in 8. So the recommended setting is
--confirm-judge enforce --confirm-second proposed --confirm-second-shadow. The judge runs only when asked for, since it needs a Jev key (results).
--commitaddsstretto_committo the tools the server lists. It takes{"calls": [{"name", "arguments"}, …]}and makes the calls in order, each checked by the guards first. It stops at the first call that is refused or fails, and returns every call's result and the ones it did not run. It is meant for the writes the user has confirmed, in one LLM turn.--context FILEgives the proxy the conversation, which MCP never carries. The host appends one JSON line per message,{"role": "user" | "assistant", "content": text}. Before each tool call and each decision, the proxy logs the new lines ascontextentries, so flows see what the customer said and guards can read their confirmation.
The proxy's own messages (its requests to the server, and its answers to the agent: refusals, commit results, results with a flow's lookups) are logged as proxy entries. stretto_trace::mcp::episode counts the first response to each call as its result, so a flow's lookups appear as calls of their own, with ids starting stretto-.
Log format
A log is JSONL. The first line is a header:
{"stretto_mcp_log":2,"session":"20260923T212000.123Z-4242","started_unix_ms":1790198400123,"server_command":["npx","-y","some-mcp-server"],"domain":"orders","agent_model":null}session is the UTC start time and the proxy's process id, and names the file. Every other line is one line from the wire:
{"t_ms":12,"from":"client","message":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup","arguments":{"text":"hello"}}}}
{"t_ms":14,"from":"server","message":{"id":3,"jsonrpc":"2.0","result":{"content":[{"text":"{\"text\":\"hello\"}","type":"text"}],"isError":false}}}
{"t_ms":15,"from":"server","raw":"Listening on stdio"}t_ms: when the proxy read the line, in milliseconds after it started. Times never decrease down the log.from:client(the host) orserver; in active mode alsoproxy(a message the proxy sent on its own) andcontext(a message of the conversation from--context).message: the line exactly as it was sent, when it is one JSON value: a JSON-RPC message, or an array of them (a batch).raw: the line as text, when it is not valid JSON (invalid UTF-8 is replaced by U+FFFD).
Lines appear in the order the proxy read them. Each is recorded just before it is forwarded, so a request always comes before its response. A writer thread writes each line out as soon as it gets it, off the forwarding path, so forwarding never waits for the disk, and a crash loses at most the lines still in flight.
Reading logs
use std::path::Path;
use stretto_trace::mcp::{episode, manifest, read_log};
let log = read_log(Path::new("/home/me/.stretto/logs/20260923T212000.123Z-4242.jsonl"))?;
let mut ep = episode(&log);
ep.reward = 1.0; // if you know the task was solved
let tools = manifest(&log, &ep.domain); // tool kinds from `readOnlyHint`Each tools/call becomes a ToolCall, and each response to one becomes a ToolResult: an error for a JSON-RPC error or isError: true; its content is the result's text. manifest reads tool kinds from the readOnlyHint annotation. true is Read, false is Write, and no hint is Generic, which here means unknown. Annotations are the server's claims, not guarantees.
What the proxy cannot see
- The conversation. User messages and the LLM's replies never reach an MCP server. Without
--context, episodes from logs have no user events, and assistant turns have no text. - LLM turns. They are inferred from timing. A call sent while an earlier call of the current turn still awaits its response joins that turn (parallel calls). So does a call sent within half a second of the turn's last response, with nothing said in between, unless it passes a value that first appeared in what the turn returned: Claude Code runs a turn's reads as the model streams them and its writes one after another, and a new LLM turn takes longer, as the model reads the results first (the measurement). The proxy's own lookups join a turn only while a call of it awaits its response. Any other call starts a new turn. A call that is never answered, nor cancelled, keeps its turn open, so later calls join it.
- Calls still on their way. When one call of a turn returns, the flow decides at that call's site, and the turn's calls the host has sent but that have not returned count as asked for already: the flow does not look them up. Calls a streaming host sends later, the proxy cannot know of.
- Outcomes, tokens and cost. Whether the task succeeded is unknown:
rewardis 0.0 until you set it, and usage is empty. - The model. Unless you pass
--agent-model, episodes name the host application frominitialize(clientInfo.name), not the LLM. - Other servers. One proxy wraps one server; an agent with several servers leaves one log per wrapped server.
Limits
- The host side is stdio only: the proxy does not serve Streamable HTTP to a host. The server side can be either.
- Over HTTP, a session the server ends (a 404 on its id) is not started again: each request after it fails with a JSON-RPC error until the host restarts the proxy.
- The proxy does not forward signals. If the host kills the proxy, the server's stdin closes, which is how MCP tells a stdio server to exit; a server that ignores that keeps running.
Privacy
Logs hold every tool call and result verbatim: personal data, documents, whatever the tools read or return. Treat them like the data the tools touch, and never commit them. docs/privacy.md lists what each file holds and what is sent to the System-One model.
--retain-days N deletes, when the proxy starts, what is older than N days in --record and --oracle-cache. stretto redact writes a pseudonymized copy of sessions to share, from which stretto learn learns the same flow.
The header's server_command is the command after --, with the values of credential-looking arguments replaced by <redacted> (--api-key X, --token=X, GITHUB_TOKEN=X, Authorization: Bearer X). That is best effort, so pass credentials to servers through env: the proxy never records its environment.