Vayu MCP Server¶
Endpoint: http://127.0.0.1:9877/mcp (Streamable HTTP) · Also: stdio CLI
Vayu exposes its engine to AI agents (Claude Code, Cursor, VS Code, Codex, Zed)
through a Model Context Protocol server. The
server is TypeScript, hosted in the Electron main process, built on the official
@modelcontextprotocol/sdk,
and proxies the engine's REST API on :9876. The C++ engine is not modified -
the MCP layer is Apache-2.0 like the rest of the app.
Once Vayu is running, any agent opts in with one command; if Vayu is down, the
agent gets a clean "start Vayu" error. Threat model and posture: SECURITY.md.
Overview¶
- Hosted in the app. MCP is a capability the running app exposes, not a
separate process to manage. It is started and stopped alongside the engine
sidecar by
app/electron/main.ts, best-effort (a bind failure logs and the app continues without it). Only the port is bound at launch (listener.ts, which imports no SDK); the SDK, the tool registry and the service are loaded by the firstPOST /mcpthat arrives, so a launch no agent connects to never evaluates them - 5-7 MB of the main process at idle, measured on the packaged Windows app. - Proxy, not a second source of truth. Every tool maps to an existing engine
endpoint via a thin
fetchclient (engine-client.ts). The main process cannot import the renderer's@/services, so this client is standalone. - Local-only. Binds
127.0.0.1, with Host-header (DNS-rebinding) validation on/mcp. - Configurable from Settings. Server on/off, the allowlist, caps, the write toggle, and per-tool switches live in Settings → MCP and persist across restarts.
Connecting¶
Ensure Vayu is running, then register the endpoint once per machine. In the app, Settings → MCP offers a one-click Connect for Claude Code and VS Code (shells out to their CLIs) and copyable snippets for the rest.
Connect resolves the client CLI before running it, because a GUI-launched app
often has a stripped PATH. On macOS and Linux that means the login shell
($SHELL -lc, falling back to /bin/sh -lc for a shell that does not accept
-lc); on Windows it means where, preferring an .exe and then a .cmd
shim over the extensionless POSIX script VS Code also installs. A .cmd/.bat
shim is run through cmd.exe, which Node has required since 20.12. If the CLI
is not installed, or the run fails for any reason, Connect says so and the
snippet below it is the manual path.
# Claude Code (or click Connect in Settings → MCP)
claude mcp add --transport http vayu http://127.0.0.1:9877/mcp
// Claude Code (.mcp.json) / Cursor (.cursor/mcp.json)
{
"mcpServers": {
"vayu": { "type": "http", "url": "http://127.0.0.1:9877/mcp" }
}
}
// VS Code (.vscode/mcp.json) - note the "servers" key
{
"servers": { "vayu": { "type": "http", "url": "http://127.0.0.1:9877/mcp" } }
}
Client compatibility¶
MCP defines three transports: stdio, Streamable HTTP, and the legacy HTTP+SSE (deprecated; not built for). The fixed-port Streamable HTTP endpoint covers most clients with a single URL; Zed (stdio-only) uses the CLI below.
| Client | Streamable HTTP | stdio | Config location |
|---|---|---|---|
| Claude Code | ✅ (http) |
✅ | .mcp.json, ~/.claude.json, claude mcp add |
| Cursor | ✅ | ✅ | .cursor/mcp.json, ~/.cursor/mcp.json |
| VS Code | ✅ (servers key) |
✅ | .vscode/mcp.json |
| OpenAI Codex | ✅ | ✅ | ~/.codex/config.toml |
| Zed | ❌ not yet | ✅ | context_servers (stdio CLI) |
Transports¶
Streamable HTTP (primary)¶
listener.ts binds 127.0.0.1:9877 at launch and answers what needs no SDK
(gates.ts: non-/mcp paths 404, GET/DELETE 405); http.ts serves
each POST /mcp once the first one has loaded it. Serving is stateless:
each POST /mcp gets a fresh SDK server + transport
(sessionIdGenerator: undefined, enableJsonResponse: true).
DNS-rebinding protection is on (Host must be 127.0.0.1:9877 / localhost:9877).
A body that is not valid JSON is answered 400 with JSON-RPC -32700 (parse
error), and one over the 4 MB cap 413 with -32600 - past the cap the rest of
the upload is drained and discarded rather than the socket being closed under it,
since resetting the connection mid-upload loses the very response the status code
exists to deliver. The cap bounds what is held in memory, not what crosses the
wire. The body is read before the transport sees it, so these are answered
directly rather than by the SDK. Both messages are fixed strings - nothing
derived from the underlying error reaches the wire - and -32603
("Internal error") is left to mean a genuine handler failure, including a socket
error while reading.
The per-request rebuild means Settings changes (allowlist, caps, disabled tools)
take effect on the next request with no extra bookkeeping.
stdio CLI (Zed / headless / CI)¶
cli.ts is a standalone stdio server that reuses the same server factory and
tool registry. It is for stdio-only clients (Zed) and headless/CI. Run:
Configuration comes from environment variables (see Configuration), since there is no Settings UI. It still requires a running engine.
Logging (#1558): stdout is the JSON-RPC channel, so every diagnostic goes
to stderr as text, unconditionally - never gated by VAYU_LOG_CONSOLE the way
the Electron-hosted host's console rendering is. Set VAYU_LOG_DIR to also
write a JSON-lines mcp_<stamp>.log there (unset: console only) - its own
prefix, since this process shares no file or data directory with the Electron
main process. A call served through this transport and one served through the
Electron-hosted transport each leave a src: "mcp" record; see
Engine Logging.
What is live on each transport¶
Elicitation (human confirmation) and tools/list_changed (live tool-set updates)
need a server→client channel, which exists on stdio but not on the stateless
HTTP host. On HTTP they degrade gracefully: load-run confirmation falls back to a
confirmed: true flag, and a tool toggle applies on the client's next
tools/list. Both are safe on HTTP, just not instantaneous. See
Design notes.
Tools¶
Every tool carries a category (surfaced in Settings for enable/disable), MCP
annotations (readOnlyHint / destructiveHint / idempotentHint /
openWorldHint + a display title), and a Zod input schema (arguments are
validated by the SDK). A few declare an outputSchema and return validated
structuredContent alongside the text rendering.
The four categories partition tools by what they can do - and thus which gate applies: read (inspection, always safe), execute (has an effect outside this process without touching saved data - allowlist when it sends real traffic to a target, none when the effect is a loopback service the engine hosts, as for the mock issuer), write (mutates saved data or engine config - write toggle), load (starts/stops load tests - allowlist + caps + confirmation).
| Tool | Category | Maps to | Gate |
|---|---|---|---|
get_engine_health |
read | GET /health (structured) |
- |
list_collections |
read | GET /collections |
- |
list_requests |
read | GET /requests?collectionId= |
- |
list_environments |
read | GET /environments |
- |
list_runs |
read | GET /runs?limit=&offset=&type=&status=&requestId=&collectionId=&q=&baseline= |
Page of the {data, pagination} envelope, newest first; 100 rows by default, 500 max (refused above, not clamped); rows carry a compact summary |
get_run_report |
read | GET /runs/:id/report |
Stored trace bodies capped at 32 KB per node, and 96 KB across the report |
get_run_samples |
read | GET /runs/:id/samples?limit=&offset= |
25 samples per call by default, 500 max |
get_run_timeseries |
read | GET /runs/:id/metrics?limit=&offset= |
100 ticks per call by default, 1000 max - the engine's own cap is 50000 |
get_run_monitor |
read | GET /runs/:id/monitor?limit=&offset= |
Same bounds as get_run_timeseries |
get_engine_config |
read | GET /config |
- |
get_live_metrics |
read | SSE snapshot of last N ticks | limit must be a whole number ≥ 1 |
compare_runs |
read | 2× GET /runs/:id/report → diff (structured) |
baseRunId optional - omitted, it resolves the target's pinned baseline |
run_request |
execute | POST /compose + POST /execute (+ GET /runs/:id/events when streaming) |
allowlist; response body capped at 32 KB; verifySSL: false refused - the downgrade belongs on a saved request |
run_collection_smoke |
execute | GET /requests?… + POST /compose + POST /execute (×N) |
allowlist per host |
run_collection |
execute | GET /requests?… (+ GET /collections when recursive) + POST /compose (×N) + POST /runs |
allowlist on every step - one step off it refuses the whole run; optional thresholds budgets, the same argument start_load_run takes |
create_collection |
write | POST /collections |
write toggle; takes variables, auth and elements (extractors, assertions, timers, scripts) - preRequestScript/postRequestScript fold into script.pre/script.post sugar |
update_collection |
write | GET /collections (scan, when variables change or a script argument is given with no explicit elements) + PUT /collections/:id (merge-patch) |
write toggle; variables merges like update_environment's, removeVariables deletes names; elements replaces the stored list whole, script sugar folds into it |
delete_collection |
write | GET /collections + GET /requests?… (×N) + DELETE /collections/:id |
write toggle + confirm |
get_spec |
read | GET /collections (scan, only for collectionId) + GET /specs/:id/meta, or GET /specs/:id with includeContent |
- (document text off by default and capped at 32 KB; a collection binding nothing answers bound: false) |
diff_spec |
read | POST /specs/diff |
- (each bucket capped at 50 entries, with summary carrying the true totals; the per-entry draft is dropped) |
bind_spec |
write | POST /specs/bind |
write toggle; one transaction - stores the document, moves the binding, stamps what matched and clears what no longer does |
sync_spec |
write | POST /specs/sync (policy: "safe") |
write toggle; one transaction - stores the document, moves the binding, creates and updates requests; deletes nothing and overwrites no hand-edited field, with skipped counting what it declined |
export_spec |
read | POST /specs/export |
- (document text capped at 32 KB, with contentBytes for the true size; notes says what the export could not carry) |
unbind_spec |
write | GET /collections (scan) + PUT /collections/:id (openapi: null) |
write toggle; the document and the requests' recorded operations are kept |
import_document |
write | POST /import |
write toggle; one transaction - every format the app accepts (OpenAPI 2.0/3.x, Postman v2.0/v2.1, a Postman environment or globals export, Insomnia v4), detected by content; meta.skipped names what the document declared and Vayu cannot represent |
create_request |
write | POST /requests |
write toggle; takes the builder's whole surface - auth, followRedirects / maxRedirects / httpVersion / stream / verifySSL, elements (extractors, assertions, timers, scripts) - minus file body parts |
update_request |
write | GET /requests/:id (scan, only for a script argument with no explicit elements) + PUT /requests/:id (merge-patch) |
write toggle; same fields, and only the ones named are written; elements replaces the stored list whole, script sugar folds into it; mockResponseMode / mockExampleId set which saved example a mock answers with, fixed refused without an example id |
delete_request |
write | GET /requests/:id + DELETE /requests/:id |
write toggle + confirm |
list_trash |
read | GET /trash |
- |
restore_trash_entry |
write | POST /trash/:id/restore |
write toggle (not destructive - no confirmation) |
purge_trash_entry |
write | GET /trash + DELETE /trash/:id |
write toggle + confirm |
list_request_examples |
read | GET /requests/:id/examples |
- (bodies capped at 32 KB each, 96 KB across the list) |
create_request_example |
write | POST /requests/:id/examples |
write toggle; always stored as origin: "user" - an agent cannot claim an import |
update_request_example |
write | PUT /requests/:id/examples/:exampleId (merge-patch) |
write toggle; origin is not writable |
delete_request_example |
write | GET /requests/:id/examples + DELETE /requests/:id/examples/:exampleId |
write toggle + confirm (the prompt names the example and the mock consequence) |
move_item |
write | GET /collections or GET /requests?… + POST /reorder |
write toggle; first / last only, and a collection into its own subtree is refused before the engine sees it |
create_environment |
write | POST /environments |
write toggle (the engine assigns the id; created inactive) |
update_environment |
write | GET /environments (scan) + PUT /environments/:id (fetch-merge) |
write toggle; variables takes a string or {value, secret, type, enabled}, removeVariables deletes names |
activate_environment |
write | PUT /environments/:id (isActive), + GET /environments for "none" |
write toggle; one PUT - the engine deactivates the previous row in the same transaction |
delete_environment |
write | GET /environments (scan) + DELETE /environments/:id |
write toggle + confirm (the prompt names the variable count) |
get_globals |
read | GET /globals |
- (answers an empty set, never a 404) |
resolve_variables |
read | GET /globals + GET /collections + GET /environments - composes, no single endpoint answers it |
- (secret values withheld from the report) |
update_globals |
write | GET /globals + POST /globals (fetch-merge) |
write toggle; POST replaces the blob, so the read is what makes it a merge |
get_cookies |
read | GET /cookies |
- (values included, as the Settings card shows them) |
clear_cookies |
write | DELETE /cookies[?environmentId=] |
write toggle; omitted clears every jar, null the no-environment jar, an id that environment's |
set_run_baseline |
write | PUT /runs/:id/baseline |
write toggle |
delete_run |
write | GET /runs/:id + DELETE /runs/:id |
write toggle + confirm |
update_engine_config |
write | POST /config |
write toggle |
start_load_run |
load | POST /compose + POST /runs, or (with scenario) GET /requests?… + POST /compose (×N) + POST /runs |
allowlist + caps + confirm; optional data rows (single target) or scenario.data (sequence); optional thresholds budgets and monitor server-vitals block; mode accepts constant_rps | constant_concurrency | ramp_up | iterations | capacity, narrowed to the middle three for a scenario; the recording knobs and comment below apply to both shapes, the redirect policy to a single target only |
stop_run |
load | POST /runs/:id/stop |
- |
fetch_oauth2_token |
execute | POST /oauth2/token |
allowlist, on accessTokenUrl and refreshTokenUrl; authorization_code refused before the call; the access token is never returned |
get_oauth2_token_status |
read | GET /oauth2/token?key= |
- (an absent entry is found: false, not a 404); the access token is never returned |
clear_oauth2_token |
write | DELETE /oauth2/token?key= |
write toggle; idempotent - deleted: false when nothing was cached |
start_mock_issuer |
execute | POST /mock-issuer/start |
- (loopback-only listener, so no allowlist entry applies); limits are the engine's - 31-day expiry, 60s slowMs, 32 clients, 8 concurrent issuers |
list_mock_issuers |
read | GET /mock-issuer |
- |
stop_mock_issuer |
execute | POST /mock-issuer/:id/stop |
- (unknown id is a 404, surfaced as a tool error) |
update_mock_issuer |
execute | PUT /mock-issuer/:id (merge-patch) |
- (live edit of failureMode / slowMs; an empty patch is refused before the engine sees it) |
start_mock_server |
execute | POST /mock/start |
- (loopback-only listener, so no allowlist entry applies); the latencyMs ceiling is the engine's |
list_mock_servers |
read | GET /mock |
- (running mocks only - a stopped one has no record) |
get_mock_routes |
read | GET /mock/:id/routes |
- (a start-time snapshot, constant under a running mock, except each row's hits count) |
get_mock_activity |
read | GET /mock/:id/activity?limit= |
- (newest first, 50 rows by default, 200 max; discarded when the mock stops, and re-fetched to see new entries rather than pushed) |
stop_mock_server |
execute | POST /mock/:id/stop |
- (unknown id is a 404, surfaced as a tool error) |
start_webhook_inbox |
execute | POST /inbox/start |
- (loopback-only listener; bind / confirmNonLoopback are never sent) |
list_webhook_inboxes |
read | GET /inbox |
- |
stop_webhook_inbox |
execute | POST /inbox/:id/stop |
- (frees the port, keeps the record and its captures) |
delete_webhook_inbox |
write | GET /inbox + DELETE /inbox/:id |
write toggle + confirm (the prompt names the capture count) |
get_inbox_captures |
read | GET /inbox/:id/requests?limit=&offset= |
25 captures per call by default, 100 max; each body capped at 32 KB |
clear_inbox_captures |
write | DELETE /inbox/:id/requests |
write toggle |
update_inbox_response |
execute | PUT /inbox/:id (merge-patch) |
- (live edit of the canned reply) |
Notes:
start_load_runrequires confirmation - via elicitation when the client supports it, otherwise aconfirmed: trueflag - and enforces the RPS / concurrency / duration caps. Incapacitymode the concurrency cap bounds the search's ceiling (concurrency) and its starting level (startConcurrency), which is what stops an adaptive run from outgrowing it;sloMsandstepDurationare that mode's own two fields, andget_run_reportreturns the search's findings undercapacity. The duration cap accounts for the mode's own engine-side default deadline (5 minutes, not the 60s other modes fall back to), so a cap between those two values still injects an explicitdurationwhen the agent omits one.get_live_metricsis a bounded snapshot (SSE read with a time budget), not a stream -tools/callstays request/response.- What a load run keeps is settable too (issue #760), which is what the
MCP surface was missing rather than any part of the load shape:
successSamplePeriod(the engine'ssuccess_sample_rate- a period, keep 1 in N, not a percentage; the argument is named for what the value means and the payload key stays the engine's),slowRequestThresholdMs,saveTimingBreakdownandcomment. All four reach a scenario run too: both executors read them off the oneRunContext, andcommentis lifted into the run summary and the report'smetadata.configurationwhichever produced the run. Bounds mirrorvalidate_run_config, so a value the schema accepts is onePOST /runsaccepts, and an absent knob stays absent - each has an engine default a stated value would overwrite. The redirect policy (followRedirects/maxRedirects) belongs to the request half instead: it rides throughPOST /composebeside the method and the body, overriding a saved request's stored policy or supplying the only one an ad-hoc target has. A scenario run refuses both by name, as it does every other single-target field - each step keeps the policy stored on it.maxRedirectsis bounded 0-100 here becausePOST /runshas no guard of its own and the value reachesCURLOPT_MAXREDIRS, where a negative means unlimited. There is no per-run timeout, and that is not an omission: the engine has no such field - every transfer is bounded by thedefaultTimeoutsetting (resolve_request_timeout_ms), whichupdate_engine_configchanges. Recorded here so it is not re-derived as a gap each time the schema is read. - An agent can bind a collection to a spec now, export one, ask whether a
contract has drifted and apply the safe half of that drift (issues
#862,
#855 and
#871) and import a document
outright (issue
#877).
get_specsays what a collection is bound to,diff_specsays what a re-fetched document would change about it,sync_specapplies the part of that a caller with no opinion should apply,bind_specbinds one,export_specwrites it back out as a document, andunbind_specdetaches it.bind_spectakes the document text and nothing else - no pairing - because everything it needs is engine-side after #761's phase B: the engine
reads the document (issue #853), derives both indexes from it - the
operation index and the response-schema index, dialect translation included
(issues #629, #628, #860) - and matches the collection's subtree against the
index it just derived, through the same
core::operation_match.hpprulePOST /specs/matchpreviews with. So a document an agent sends as bytes reports coverage and validates responses, with no OpenAPI reader on the agent's side, andPOST /specs/exportowns the assembly that sends one back. A bind writes identity in both directions, which is what kept the tool out through phase A: re-binding to a different document clears every stamp that document does not account for, because coverage resolves a stamp byoperationIdand a surviving one claims the wrong operation rather than none. The tool result reportsclearedbesidestampedfor that reason - it is the half a caller would otherwise discover from a later run.unbind_specstill leaves stamps alone, so unbind-then-rebind of the same document costs nothing. import_documentis the fourth verb, and the one that took a parser move (issue #877). Every other spec tool had shipped while this one could not:POST /import/applytakes a parsed tree, and the four parsers that built one lived in the renderer, so an agent could bind, diff, sync and export a contract and not import a document. They are engine-side now -core/import_document.hpp, reading through the samecore::read_documentthat answers what a stored document declares - so the tool sends bytes and options and nothing else, and detection is by content rather than by aformatargument a caller could get wrong.POST /importis the parse, the flattening andPOST /import/applyin one call: the tree lands atomically, and the globals a Postman globals export carries are merged afterwards rather than written over, becausePOST /globalsreplaces the whole set and must not run in front of a write that can still fail. The caveat sentence namesmeta.skippedfor the reasondiff_specnamesuserTouched: an import that dropped a WebSocket request, a file body or an operation'sdefaultresponse looks exactly like one that had none. External$refs are not followed - resolving one means fetching a URL or reading a file beside the document, which is the import dialog's business (a URL proxy and a gated IPC), so a multi-file spec imports whole and says nothing about the files it names.diff_specis the read half of a sync (issue #871). It sends the collection and the candidate document and nothing else: the requests and the bound document are the engine's to read, because a caller that could supply the "previous" side of a three-way comparison could turn its own edits into the document's. Three things about the answer are this tool's rather than the route's. The per-entrydraftis dropped - it is what an apply would write, andPOST /specs/syncre-reads it off the document it stores rather than being handed it, so nothing on either side of the tool consumes it; the renderedcurrent/nextpair, which is what says what moved, survives. Each bucket is capped at 50 entries whilesummarykeeps the engine's true totals, so a document that renamed every operation comes back described rather than whole. And a field flaggeduserTouchedis named in the caveat sentence, not just carried in the JSON: it is the one part of a drift an apply may not take silently, and an agent reading "6 changed" would otherwise propose overwriting somebody's edit.sync_specapplies a drift, and does not let the agent choose which of it (issue #871). It sendspolicy: "safe"and nothing else: the engine works out the rows fromcore::safe_spec_apply, the same function whose answerdiff_specreports per entry assafe/safeFields. That is the whole design. Which of a drift is safe to write - every operation the document adds, every field it moved that nobody had edited by hand, no deletions, and a request whose bound document could not be read left alone whole - used to live in the renderer alone (spec-apply.ts,defaultSelection), which is why applying a drift was app-only through phase 1:electron/may not importsrc/, so any tool would have needed a copy, and a copy of that rule is a second opinion about which of a user's fields a sync may overwrite. The rule moved engine-side instead, anddefaultSelectionnow reads the marks rather than deriving them, so the Spec tab's pre-ticked boxes and an agent's sync are one answer. A person who changes a tick still sends explicit rows - a choice a person made is not a policy. The tool result'sskipped(requests untouched, fields not written, deletions not made) is named in the caveat sentence fordiff_spec's reason: a caller that stated no ticks cannot see what it did not tick, and "5 updated" alone reads as "applied the drift".get_run_reportcarries contract coverage for a run of a collection bound to an OpenAPI document (issue #629): which of the contract's operations the run exercised, which of their declared responses it saw, and any statuses the document never declared, undercoverage. Passed through verbatim - the tool adds nothing. Absent, never zeros, for a run that was not measured against a contract, so an agent must branch on the key's presence rather than reading a zero as full non-coverage.- Bodies are bounded before they reach an agent (issue #767).
run_requestandget_run_reportwere raw passthroughs, and no engine cap covers this case:maxResponseBodyBytesbounds load runs only ("Design-mode sends are not affected"),maxTraceBodyBytesis 5 MB, sized for the database and a human reading one full trace, andmaxDesignResponseBodyBytes(issue #1157) does bound a design send but at 32 MB, three orders of magnitude past what a tool result can carry. So a single ordinary page fetch answered with 1.3 M characters and blew the tool-result token limit outright. Both tools now cap a body at 32 KB -maxSampleBodyBytes, the engine's own answer to how much of a body an automated reader gets, rather than a new number. What a cut looks like, in the engine's existing vocabulary (cap_node_body,run_samples_response):bodyTruncated: truebeside the full size, inbodySizeon arun_requestresponse andbodyByteson a stored trace node, andrawRequestTruncated/rawRequestBytesfor a cut wire message - whose headers are always kept whole, since theCookieline libcurl attached appears nowhere else. A cutbodyRawcomes back with its parsedbodyasnull, because the two carry the same payload and an intactbodywould return in full exactly what was just dropped. A trace the engine had already truncated keeps the original size the engine recorded. Under the bound, nothing is added and nothing is changed.bodyCappedon arun_requestresponse is not this (issue #1157): it is the engine's own flag, always present, saying it stopped reading the response atmaxDesignResponseBodyBytes, sobodySizeis the prefix it read and re-sending returns the same amount - only raising that config entry changes it, wherebodyTruncatedsays the full body is still in the app's history. It is passed through untouched and both can be true of one response. Load-run reports are unaffected: a load run's results never go throughbuild_result_trace, so they carry no trace node at all, and its captured bodies live behindGET /runs/:id/samples, which has always truncated and disclosed this way. - The traces are bounded as a set, not only one at a time (issue #769).
Capping each node does not cap the report:
/runs/:id/reportreturns up to 100 rows and each may keep 32 KB on each of three nodes, so a 100-step scenario measured 3.3 M characters with every node honestly flagged as truncated - 2.5x the size that failed in #767. At that row count truncation is no longer the binding constraint: 100 steps of an 8 KB body, under the per-node cap and so never touched, still totalled 845 K. Soget_run_reportalso holds the traces to 96 KB in total -MAX_INLINE_BODY_BYTES * 3, the largest single row the per-node bound can produce, rather than a new number beside it. Rows past the budget keep every scalar (id, status, latency, step identity) and carrytraceOmitted: truein place of their trace, withtracesOmittedandtraceBudgetByteson the report. Non-passing steps spend the budget first, matchingScenarioStepStore::add's own rule forstepsStored, so the two do not disagree about which steps matter - and the rows come back in run order regardless, since the budget decides what a row carries, never where it sits. The first trace is always embedded whatever it costs, so a design run's single-row report never comes back empty. The rows themselves are not capped further: at ~200 bytes of scalars each they are noise beside one body, and the run's shape is the answer even when the payloads cannot come along. Bounded sizes for the fixtures above: 33 K characters for the single 1 MB row, 76 K for 100 steps x 1 MB, 120 K for 200 rows with all three nodes populated (24.7 M unbounded). start_load_run'sstreamflag consumes each response as atext/event-stream(issue #576), withmaxStreamDurationMsandmaxStreamEventsbounding one stream. Both caps are forwarded verbatim on thethresholdsprecedent - their ranges are the engine's, and re-deriving them here would be a second copy to keep in step - and the schema sends nostreamkey at all when the agent named none, because the engine refuses a cap without the flag and refuses the flag besidetransient. Worth telling an agent explicitly: reaching either cap completes the stream successfully, so a streaming run's 0% error rate is not evidence the caps were never hit -get_run_report'sstream.cappedis what answers that. The report'sstreamsection also carries the per-completion event distribution and a derivedeventsPerSecond.start_load_run'smonitorblock scrapes the target's own metrics endpoint for the life of the run (url, optionalintervalMsandformat, and theseriesnames to read), so an agent asked why a target slowed down can read its CPU on the same timeline as p99 - the report comes back with amonitorsection carrying per-series min/max/avg plus the sample and failed-scrape counts. The block is forwarded verbatim: its value ranges are the engine's (validate_run_config), andmonitor.series' ceiling is themonitorMaxSeriessetting, so a second copy of those bounds in the tool schema would refuse blocks the engine accepts the moment a user raises it. The monitor endpoint is a second host, and it takes the allowlist decision described under Safety rather than the target's check by extension. The scrape needs no cap of its own: the monitor thread is joined when the run ends, so whatever bounds the run bounds it.compare_runstakesbaseRunIdoptionally. Omitted, it resolves the run pinned as the baseline for whatever saved request the target ran -GET /runs/:idfor therequestId, thenGET /runs?baseline=true&requestId=<id>&limit=1- which is the same lookup the app's history view makes, so an agent and the UI never compare a run against different references. Nothing to resolve through (an ad-hoc run with no saved request), no pin for that request, or a target that is the pin: each is a refusal naming the fix, never a silent comparison against some other run. Every metric in the result carries adirection-lower-is-better,higher-is-betterorneutral(total requests, which moves with how long a run was told to run) - so a reader can tell a regression from an improvement without knowing each metric's sense.- Run housekeeping (issue #755) is the History surface an agent could
otherwise only page through:
list_runstakes the engine's own filters (type,status,requestId,collectionId,qover the stored config,baseline) pluslimit/offset, so finding one run is a query rather than a scan of 100-row blobs. Order is fixed newest-first -GET /runstakes no sort parameter, and the app's oldest-first view sorts client-side.collectionIdmatches a collection run only, since a design or load run stores none. The filters are Zod enums rather than passthrough strings because the engine ignores atypeorstatusit cannot parse: forwarded raw, a typo would answer the unfiltered page and read as "nothing matched anywhere".set_run_baselinewrites the pincompare_runsalready resolved but nothing could set (the write half #472 never shipped), anddelete_rundeletes a run and everything recorded against it behind the same write toggle + confirmationdelete_requestuses. A run still executing is stopped engine-side and deleted only once its worker settles; a worker that does not settle in time is a 409 with the run intact, surfaced as "not deleted, retry once it reports a terminal status" rather than as a generic engine error. - The stored-series reads are bounded on this side of the boundary.
get_run_samples,get_run_timeseriesandget_run_monitordefault to 25 / 100 / 100 rows against engine defaults of 50 / 5000 / 5000: those defaults are sized for the dashboard's charts, which draw every point for a human, and an agent reads the same rows as JSON through a context window. The ceilings are 500 (the engine's own page cap, shared withGET /runs) and 1000 for the two series, well under the engine's 50000. Alimitpast a ceiling is refused, not clamped - the #319 precedent - because a short page silently substituted for the one asked for reads as the whole answer. A page with more behind it says so in words beside the JSON, with theoffsetto read next. update_engine_configreads the config back after applying and flags any changed key that needs an engine restart to take effect underrestartRequiredin its structured result (read from each entry's typedrequiresRestartfield, not from its label). Such values are saved, but the running engine keeps the old value until it is restarted, so the tool says so in its text output too.- The collection / request write verbs are the CRUD an agent needs to work
unattended:
create_collectiongives it acollectionIdto file new requests under, andupdate_request/delete_requestlet it correct or remove a request it got wrong instead of leaving the cleanup to a human. The two updates are merge-patches - the tool sends only the fields the caller named, andPUT /collections/:id/PUT /requests/:idkeep everything else stored, so a patch naming justnamecannot blank a url, an auth block or a script. A patch naming nothing is refused rather than sent as a write that changes nothing, andbodyTypewithoutbodyis refused too (the blob and its denormalized column move together or the two disagree about what the request sends).update_collectioncarries a collection's own state - name, description, variables, auth, both scripts - and never its position: re-parenting ismove_item's job (below), because a move is a batch. The destination's siblings renumber with it, andPOST /reorderis the write path that commits the whole batch or none of it; aPUTwrites one row. delete_collectioncascades, so it reads the subtree first:GET /collectionsgives it every descendant throughparentId, oneGET /requests?collectionId=per collection in that subtree gives the request count, and those counts are what the confirmation states. An unreadable subtree - or an id no collection has - is a refusal, never a prompt carrying a number nobody verified.delete_requestreads the row the same way, so the prompt names the request and its URL rather than an opaque id.- The Trash tools (issue #1071) close the loop those two deletes open.
list_trashanswersGET /trashunchanged - roots only, withcollections/requestscounting what each entry's delete took, exactly as the route documents.restore_trash_entryis the one write tool here that is not destructive - it puts a row back rather than removing one - so it carries the write toggle alone, with no confirmation step; it surfaces the engine's own 404/409 text verbatim instead of writing a second sentence that could disagree with it, and reportsreparentedToRootin its result text when a restored collection came back at the top level because its parent is gone or itself in the trash.purge_trash_entryis the Trash's own hard delete and is gated exactly likedelete_collection: it reads the entry offGET /trashfirst, so the confirmation prompt names what a mistyped id would otherwise destroy sight unseen, and an entry the trash does not hold is a refusal before any prompt is shown. - A saved request an agent writes is the one the builder writes (issues
#759, #795).
create_request/update_requestcarry the request'sauthblock and the five Settings tab fields (followRedirects,maxRedirects,httpVersion,stream,verifySSL) as well as its url, headers, body and both scripts, so an agent that can send an authenticated request can now save one. Theauthinput is the same schemarun_requesttakes rather than a copy of it - one definition, four descriptions - which is what lets an agent read a request'sauthoverlist_requestsand write it back verbatim. Both the auth block and each setting follow the merge-patch rule the strings already did: named is written, absent is left alone, so an update that mentions onlymaxRedirectscannot hand a storedfollowRedirects: falseback to the engine's default. ForverifySSLthat rule is a security rule rather than a convenience: a defaultedtrueon an unrelated update would silently re-enable a certificate check the user turned off. The one exclusion left is deliberate: file body parts, which name a path on the user's machine an agent cannot choose for them.update_requestalso carriesmockResponseMode/mockExampleId(issue #481 phase 3), the same setting the Examples tab's mode picker writes:fixedis refused before the engine is called when it names nomockExampleId, exactly like the bodyType-without-body refusal above, and the change only takes effect the next timestart_mock_serverruns - a running mock's route table is a snapshot, not a live view of the request. AmockExampleIdthat later stops resolving (the example was deleted or suppressed after the write) is not refused retroactively; the mock server falls back to"first"at serve time, same as afixedmode pointed at an example that never existed. - A skipped certificate check has to leave a record (issue #795). The
stored
verifySSL: falseis writable over MCP; a per-call one is not.run_requestdeclaresverifySSLonly so thatfalseis refused by name - the refusal points at the two supported routes, adding the internal authority under Vayu Settings > Network & connectivity (verification stays on) or saving the request withverifySSL: falseand running that.start_load_run,run_collectionandrun_collection_smoketake no such argument at all; they compose saved requests, so the stored field is what they honour. The difference is what survives the call: a stored value is a document the app's Settings tab shows unticked with a warning under it,list_requestsreads back and the user can undo, while an argument on one send is visible only inside the agent's own transcript. TheallowInsecureTlssafety toggle #795 sketched was rejected for the same reason it would have been convenient - it is a global permission, granted once for one dev host and then in force for every allowlisted host, where the stored field is per request. - Examples are writable, and where one came from is not (issue #759).
list_request_examplesreads what a request has saved beside it - what a mock server for its collection answers with - and the three write tools author it, so an agent that can start a mock (start_mock_server) can now author what it serves. Theorigincolumn is the one field no tool accepts: it says whether a row came from an importer (import) or from a person (user), and an OpenAPI sync replaces the first kind while leaving the second alone (#588, #655). An agent that could claimimportwould hand its own example to the next sync to overwrite, and one that could claimusercould pin a stale imported row against the document it came from - neither is the agent's call, socreate_request_examplealways storesuserand the update tool cannot restate it. Bodies are bounded on the way out for the reasonget_run_report's traces are (#767, #769): an example body is capped engine-side at 1 MB and a request may hold 100 of them. One over 32 KB comes back cut, flaggedbodyClippedwith its stored size inbodyBytes; once the list has spent 96 KB the remaining bodies are dropped withbodyOmittedand counted inbodiesOmitted, every row's scalars kept.bodyClippedis not the engine'sbodyTruncated, which says the response was already cut when it was captured - two different facts, so two different names. - Collection-level state is writable, and merges the way an environment's
does (issue #759).
create_collection/update_collectiontake thevariables,authandelementsthat shape every request below them - the same list the composer walks (compose_elementsruns the chain's elements before the request's own, issue #1514).preRequestScript/postRequestScriptstay as sugar on these two tools (issue #1517): a non-empty string folds into ascript.pre/script.postentry and an empty one removes it - never sent to the engine under those two names, whichPOST /collections/PUT /collections/:idrefuse outright now thatelementsis the only script source.variablesusesupdate_environment's input and its rules unchanged, includingremoveVariablesand the "a new variable carries a value" refusal, because it is the same blob shape and a second dialect would be a second thing to learn. The read that makes each a merge is only done when it is needed: variables actually changing, or a script argument with no explicitelements(which needs the stored list to fold the sugar into without dropping the collection's other elements) - passingelementsdirectly, like a plain rename, sends onePUTand nothing else. A collection is the root of an auth chain and never inherits, which the engine enforces -{mode: "none"}is how a collection stops being an auth source. move_itemis a bounded move, not a reorder (issue #759). It maps toPOST /reorder, whose batch validates and commits under one acquisition of the engine's DB mutex (#386) - which is why re-parenting goes here rather than throughPUT /collections/:id's ownparentId. ThatPUTholds its own read to its write since #1440, so two concurrent moves no longer each pass an acyclicity check neither one's commit was visible to; what one row at a time still cannot do is refuse the pair together - the first move is committed by the time the second is rejected - or renumber the destination's siblings in the same write. What the tool offers is the row menu's "Move to...": a destination, andfirstorlastamong its new siblings. Positions in between stay a UI gesture on purpose - naming one means reproducing the app's ordering arithmetic (modules/collections/reorder-math.ts) from outside it, and getting it wrong is a folder that visibly reshuffles. The batch states the destination block's whole arrangement rather than leaning on the engine'snormalizepass, because normalization runs before the moves and would leave a row moved to the end of a block it is already in tied with the sibling it displaced; only the rows whose storedorderactually changes get an entry. A collection may move to the top level (parentId: null) and a request always belongs to a collection. Moving a collection into itself or into its own subtree is refused here, walking the same tree the app's "Move to..." dialog walks, so the answer names the problem - the engine refuses the same batch under its lock, and that check stays the authority.update_environmentfetches the environment and merges the supplied variables (PUT /environments/:idreplaces the whole variables blob), so partial updates preserve untouched variables and the name. Overwriting an existing variable changes its value only - itssecret,type,createdAtand enabled/disabled state are preserved, so a rotated secret stays masked and a disabled variable stays disabled. It is aPUT, not aPOST: since #95 the engine'sPOST /environmentsis create-only, and since #97 it rejects a body carrying anidoutright.create_requestandcreate_environmentstayPOSTs for the same reason - they create, and let the engine assign the id. No tool here sends anidin a body: on thePUTthe path is the identity, a bodyiddisagreeing with it is a400, and on thePOSTanyidat all is one.- The variables an agent writes are the flags it did not state (issue #758).
update_environmentandupdate_globalstake each variable either as a string - set the value, keep every flag - or as an object{value, secret, type, enabled}whose omitted fields keep their stored setting, which is what makessecretandenabledreachable at all without a read-modify-write dance on the agent's side. Two rules make that safe rather than merely convenient: a variable the blob does not already hold (or holds malformed) must carry avalue, so{secret: true}against a mistyped name is an error instead of a new empty secret variable; and a name in bothvariablesandremoveVariablesis refused, because "set it and delete it" has no correct order and guessing one would apply half the call and report success.removeVariablesis the delete a blank value cannot express -""leaves the name resolving to an empty string - and a name that was not there comes back as a note on the result rather than an error, so a retried call does not fail on its own success.secretis app-side masking only: MCP reads (list_environments,vayu://environments) still return every value in full, which is a recorded pre-1.0 security item, not something these tools changed. - Activation is one write, and
"none"is the other direction.activate_environmentsendsisActive: trueand nothing else: the DB layer clears the previously active row in the same transaction (deactivate_other_environments_locked), so a companion deactivate would be a second definition of the same rule. There is no "no environment" row to writetrueto, so"none"reads the list to find the row holding the flag and writesisActive: falseto it - and when nothing is active it writes nothing, says so, and emits no data-changed event. The app follows either direction:useActiveEnvironmentRestoreadopts whatever the engine reports, including a clear it has seen the engine hold a selection before. update_globalshas to read first. Globals is the one resource with no create/update split - one row, one id - soPOST /globalssaves the blob whole and an absentvariablesmeans{}, not "keep". The tool readsGET /globalsand posts the merged result, which is the same read-merge-writeupdate_environmentdoes for the same blob-replacement reason.clear_cookieshas three scopes, not two. OmittingenvironmentIdclears every jar, passing an id clears that environment's, and passingnullclears the jar used when no environment is selected - the engine reads an absent query parameter and a present-but-empty one differently, so omitting and passing null are genuinely different calls (the renderer'sapiService.clearCookiessends the same three). No confirmation gate: nothing saved is lost, only session state a re-login restores - which is why it is awritetool for the toggle and not one of the confirm-gated deletes.run_collection_smokeruns each saved request once and returns a structured pass/fail matrix (2xx–3xx status + all tests passing = pass). Each request is composed exactly as the app's Send would (see Request composition below). A request whose scripts asserted anything carries atestsnode -total,failed, and the failingname: messagelines (issue #733) - so a row that fails on its tests says which, rather than leaving an agent withok: falsebeside a200. Both scripts count, and a pre-request assertion's line is prefixed[pre-request](issue #810): it failed before the request went out, which is a different thing to go and look at. The list is cut at ten, the number the engine caps a schema verdict's failures at, whilefailedstays the true count. A response that ran no assertions carries notestsnode: none ran is not all passed. For a collection bound to an OpenAPI document each row also carries aschemaverdict (issue #681) and folds it intookthe waytestResultsfolds: a response the document declares a schema for and that does not match it fails the request, with the failing JSON Pointers listed so an agent need not re-run to learn where.failOnSchemaError: falseunfolds it (issue #720): the verdict still rides every row, it just stops decidingok- useful against a document known to lag its API. It defaults to true here, where the same-named flag onPOST /runsdefaults to false, because this tool has folded since #681 and an agent reading its matrix would otherwise start seeing contract failures pass.run_collectionoffers the same flag with the engine's default (issue #766, Scenario runs below); one schema fragment words both, so the two can differ only in the unit they judge and the way they default. Only a checked verdict can fail a row -checked: false(no declared schema for the status or content type, a body that is not JSON) is reported and never counted against the run, and a collection bound to nothing carries noschemafield at all. Requests whose host still can't be verified after resolution (e.g. a variable did not resolve and allow-all is off) are skipped, not sent. It does not recurse:GET /requests?collectionId=serves a collection's direct requests, while collections nest viaparentId, so a run on a parent folder tests none of its descendants. The result appends a note naming the sub-collections it left out (and says so explicitly if the collection list could not be read), because a matrix whosetotalsilently excludes nested folders reads as a whole-collection pass. Requests run serially, so a large collection takes as long as its requests do added together.- The OAuth 2.0 token tools (issue #760) are how an agent gets a token
problem named instead of discovering it as a wall of 401s inside a run:
fetch_oauth2_tokenacquires (or force-refreshes) a token for a config and caches it engine-side,get_oauth2_token_statussays whether an entry exists and whether it has expired, andclear_oauth2_tokendrops one. The config is the same block a saved request'sauthcarries, so it can be copied out oflist_requestsverbatim; the cache key is the engine's (accessTokenUrl+clientId+credentialsId+ username), so configs differing only in scope share an entry and a distinctcredentialsIdis what separates them. Two rules here are security decisions rather than plumbing, and both are stated in the tool descriptions so an agent reads them before it reads a refusal. No tool returns access-token bytes. The engine is what applies a token to a request, so the bytes buy an agent nothing it can use through Vayu, while handing them over would turn a credential the user acquired into something an agent can carry off the machine; what comes back is the entry's shape - key, type, scope, expiry, whether a refresh token came with it - plus an explicitaccessTokenWithheld, because an agent that finds no token and is not told why concludes the acquisition half-failed. And theauthorization_codegrant is refused before the engine is called, not merely because the browser exchange is one MCP cannot drive:acquire_tokenanswers a cache hit before it looks at the grant, so a call naming a config that happened to match an entry the user authorized interactively would otherwise reach into it. The refusal names the app's Auth tab as the place to authorize, and the entry that lands there is the one these tools then read. The allowlist gate coversrefreshTokenUrlas well asaccessTokenUrl- a gate that read one of two URLs is a gate a config can walk around. - The mock-issuer tools let an agent asked to "test this auth flow" mint its
own tokens:
start_mock_issuerstands up a local OAuth 2.0 issuer and returns itsissuerId,tokenUrl,authorizeUrlandsigningKey, so the agent can point a request'soauth2auth at the token URL,run_requestit, and assert on what the target received - offline, with no real provider's 2FA prompts or rate limits in the loop.expiresInSecondsplusissueRefreshTokensis how the 401-then-refresh path is exercised, andfailureModeis how retry handling is. No allowlist entry is needed and none is checked: the engine binds every issuer to127.0.0.1and takes no host for it, so an issuer is unreachable off the machine; the per-tool switch is what turns these off. The start body is forwarded verbatim under the engine's own key names, and the engine's limits (31-day expiry, 60sslowMs, 32 clients, 8 concurrent issuers) stay engine-side rather than being restated in the tool schema, for the same reasonmonitor's ranges are - a second copy would refuse values the engine accepts the moment either side moves. The schema owns the shape: a claims object, an integer port, afailureModefrom the closed set. Stopping an issuer frees its port; tokens it already minted stay valid until they expire, since nothing verifies them against a live issuer. A running issuer is edited, not recreated:update_mock_issuermerge-patchesfailureModeandslowMslive (issue #757), so an agent can mint a token against a healthy issuer, flip it toserver_errorto watch the client retry, and flip it back without the token URL under test moving or the signing key changing. Those two are the only settings a bound listener will take - the engine refusesport,clients,claimsandissueRefreshTokenswith "stop it and start a new one", so the tool does not offer them at all rather than offering a call that always fails.expiresInSecondsis mutable engine-side and is also left out: a token's lifetime is fixed when it is minted, so changing it says nothing about the tokens an agent already holds. An empty patch is refused here rather than forwarded, because the engine accepts one and answers200- which would report a change that did not happen (theupdate_inbox_responseprecedent). - The mock-server tools are how an agent stands up the API a client under
test expects, out of the collection's own saved examples (issue #757, over the
engine's mock server from #481):
start_mock_serverbinds a listener that answers each request's example - status, headers, body - and returns itsmockIdand base URL,get_mock_routeslists what it will serve, andstop_mock_serverfrees the port.latencyMsanderrorRatePctare how a client's timeout and retry handling get exercised;0and100percent are exact by construction, in between it is a per-request roll. A started mock is not necessarily a usable one, which is why the result carries a caveat rather than leaving the counts in the JSON: a route whose request has no saved example answers501, a path matching nothing answers404, and a collection with no mappable requests serves nothing at all. Loopback-only for the same engine-side reason as an issuer -mock_server.cppstarts every listener on127.0.0.1with no host to configure - so no allowlist entry is needed or checked. A stop is a delete here, unlike an inbox: a mock records nothing, so its record dies with its listener and it simply leaveslist_mock_servers. The route table is a snapshot taken at start and cannot change under a running mock (editing the collection means restarting), which is why the renderer holds it atstaleTime: Infinityand astop_mock_serverevent carries themockIdso that cache entry is dropped rather than refetched into a404. Each row'smode,exampleNameandhitsare the exception - they read off the request's stored mock settings and the mock's own hit counter, so they can change between two reads of the same table even though the routes themselves cannot.get_mock_activityis the opposite of that snapshot: it is a log that grows for as long as the mock runs, newest first, so an agent watching a client under test calls it again to see what has arrived since the last read rather than trusting a cached copy - unlike the route table, which is worth fetching only once. It is capped at 200 rows per call (50 by default) and, like the route table, is discarded outright onstop_mock_serverrather than kept the way an inbox keeps its captures. - The webhook-inbox tools are the assertion half an agent testing a webhook
needs (issue #756):
start_webhook_inboxstands up a local inbox and returns its URL,get_inbox_capturesreads back what arrived - method, path, query, headers, body, caller address - andupdate_inbox_responsechanges what the sender sees, live, so one inbox can answer200for the first trigger and503for the next. Loopback-only, and not by policy alone: the engine'sbind/confirmNonLoopbackpair is never emitted byinboxStartPayload, whatever arguments a call carries, so an inbox MCP started cannot be reached off this machine - a stated non-goal of epic #753, guarded in the one function that builds the body and mutation-checked intools.test.ts. A stop is not a delete:stop_webhook_inboxfrees the port and leaves every capture readable, whiledelete_webhook_inboxdestroys them and therefore takes the write toggle and a confirmation whose prompt names how many captures go with it (read fromGET /inboxfirst, the waydelete_runreads the run).clear_inbox_capturesis the middle case - recorded data destroyed, listener kept - so it takes the toggle without the confirmation. No live stream:GET /inbox/:id/liveis single-watcher (a second is a409) and the app's own inbox tab may hold it, so MCP pollsget_inbox_capturesinstead - the same bounded-snapshot postureget_live_metricstakes. Capture bodies are cut to 32 KB for the result with the engine's ownbodyTruncated/bodyBytesdisclosure kept intact, sinceinboxMaxBodyBytesreaches 8 MB and a webhook payload is whatever the sender sent. - Cancellation: the
AbortSignalthe SDK fires onnotifications/cancelledis threaded into the enginefetchfor every tool call, resource read, template list and prompt, so a client cancelling an in-flight call actually aborts it rather than leaving the engine request running detached. The one exception is the run-ID completion callback, which the SDK invokes as(value, context?)with no request context to carry a signal. - Timeouts: engine-local calls are bounded at 35s, but
POST /executewaits on a third-party server, so its budget is derived from the engine's owndefaultTimeoutsetting (read per call fromGET /config, up to its 300s ceiling) plus 10s of grace - the same rule the renderer uses for its proxied calls. That way the engine's ownTIMEOUTerror, with its error code and its run row, arrives before this client gives up. If the budget does expire, the tool says the call may still have completed and points atlist_runs; it is never reported as an unreachable engine, because a retry could re-send a request that already went out.
Request composition¶
The engine owns request composition (POST /compose, issue #226): it
resolves {{variables}} and walks the collection ancestor chain to resolve
inherit auth, then returns the execute-ready payload POST /execute /
POST /runs accept unchanged. Composition is pure - nothing is sent - which
is exactly what MCP's safety model needs: every execute/load tool composes
first, checks the allowlist against the composed (resolved) URL, and only
then executes the composed payload.
Two things the engine may still do to that payload after the gate has run, both
of them reasons the gate refuses a URL whose authority still carries a
{{template}} rather than trying to check one (safety.ts): a pre-request
script can assign pm.request.url, and since #1008 a name composition could
not answer is resolved once more before the send. Neither can move the request
to a host the allowlist did not see - an unresolved authority is denied
outright, and a resolved one is the one that was checked - but a tool call that
forwards a preRequestScript is not sending the composed bytes untouched, and
the allowlist is a host rule rather than a payload one for exactly that reason.
Scripts: the by-id compose path attaches the collection chain's, then the
request's own, script.pre / script.post elements under elements
(compose_elements, issue #1514 - the ordered { origin, id, name?, script }
part list compose_script_parts built is retired). The renderer's inline path
still builds its own ScriptPart[] from its editor state, a different concern
(that composer runs client-side against unsaved state, not this server).
run_request's ad-hoc preRequestScript / postRequestScript no longer ride
inside the inline request object (issue #1517): /execute refuses those two
names outright now that elements is the only script source, so the tool
reads them, folds each into an ad-hoc script.pre / script.post element,
and appends both after compose - alongside any ad-hoc elements argument the
agent gave directly - so they run in addition to whatever the composed
request already carried, never replacing it. start_load_run's ad-hoc
postRequestScript / tests folds the same way now (issue #1594):
POST /runs reads a single target's step-level elements under
requestElements, a key distinct from elements there (see below), so the
composed chain rides under that key too - composeLoadRunRequest renames the
compose response's elements onto requestElements rather than folding it
into a flat tests string.
One validation script, one name - except where the engine still keeps two.
The post-response script is one field in the app - the request builder's
Tests tab - and MCP declares it identically on run_request and
start_load_run (postRequestScript, with tests accepted as an alias) so a
script an agent writes for one reads the same on the other. Both tools now
fold it the same way too (issue #1594 caught start_load_run up to #1517's
run_request shape): an ad-hoc script.post element, appended to
requestElements. start_load_run replaces rather than joins - the
composed chain's own script.post elements are filtered out first
(tools.ts::composeLoadRunRequest), because with no way to know which
assertions the agent meant, running both would add ones they never asked for.
tests stays accepted on both as the engine's own spelling for the run
tool - a Zod object strips keys it does not declare, so removing it would turn
a script the agent believes is running into silence. Passing both names is
rejected with a ToolArgError rather than resolved by precedence: they are
one slot, and dropping either would report a run as validated by assertions
that never ran. Under load the script runs against sampled responses
(max_response_samples / response_sample_rate), not every one.
How each tool uses POST /compose (tools.ts::composeViaEngine):
- Variables - the engine resolves URL, headers, and body with the app's
precedence (environment > collection chain, leaf→root > globals; enabled
only; an unknown name keeps its braces, issue #1009; dynamic variables like
{{$guid}}generated per occurrence). MCP hands it raw strings and checks the allowlist against the composed host.start_load_runcomposes for a run, like the app, so the{{$guid}}family is deferred and the engine generates a fresh value per iteration rather than repeating one across them (issue #995); see variable resolution. - Auth -
run_collection_smokecomposes each saved request by id, so its stored auth applies (inheritresolved against the collection chain);run_request/start_load_runaccept an explicitauthblock and forward it raw inside the inline request - the engine resolves variables in it (andinherit, when acollectionIdscopes the walk) and applies it at execute (oauth2 uses its token cache). - Scripts - composing by id attaches the collection chain's + the
request's own
script.pre/script.postelements engine-side (issue #1514), so a saved request's assertions execute in the design send, the sequential collection run, and nowstart_load_run's single-target path too (issue #1594 - the composedelementsbecome the run's ownrequestElements, a step-level pipelinePOST /runsnow runs per submission).run_requesttakes an ad-hocpreRequestScript/postRequestScriptinstead (folded into elements, above), since an ad-hoc call has no chain to compose from;start_load_runtakes the samepostRequestScriptfor a URL-only run, folded intorequestElementsthe same way, as described above. Those two are supplied per call and are not stored -create_requestandupdate_requesttake the same two names as sugar for ascript.pre/script.postelement stored on the request itself, which is what lets an agent-authored script outlive the call that wrote it (see Storing a request's elements below). - Bodies -
bodyis a string andbodyTypenames the mode (json|text|graphql|jsonrpc|xml|form-data|x-www-form-urlencoded, defaulttext). The two form modes carry their content as fields, not as a string, sobodyis written askey=value&key=valueand split into thefieldsrows the engine reads - see thebodyunion. Agraphqlbodymay be the bare query document: the engine envelopes it as{"query": ...}and sendsapplication/json, and an envelope written out in full is sent unchanged - onPOST. WithmethodGETthe same fields travel asquery/operationName/variables/extensionsquery parameters instead, with no body and no Content-Type, so a mutation needsPOSTrather than theGETa new request defaults to - see thegraphqlenvelope. Ajsonrpcbodymay be the bare call object: the engine adds"jsonrpc":"2.0", plus"id":1when the call names no id, and a frame that already declares a string"jsonrpc"is sent byte for byte - which is how an agent chooses its own id or sends a notification - see thejsonrpcenvelope. Anxmlbodyhas no envelope at all: it is stored and sent byte for byte and carriesapplication/xmlunless the agent set a Content-Type of its own, which is how a SOAP 1.2 endpoint getsapplication/soap+xml.create_requeststores the same shape. Every field an agent writes is a text part: aform-datafile part names a path on the user's machine, which an agent cannot choose for them or verify, so the tools state the limit rather than inventing a shape for it. A stored file part is left alone unlessbodyreplaces the whole body. - What actually went out - the engine adds headers an agent never wrote: the
body-implied
Content-Type(agraphqlbody onPOST, or ajsonrpcbody, sendsapplication/json; agraphqlbody onGEThas no body, so the engine adds no Content-Type for it at all - anxmloneapplication/xml, anx-www-form-urlencodedone its own type), the default headers a send adds - a defaultUser-Agent, a negotiatedAccept-Encoding, and a correlation id where one is switched on - and theCookieline the jar matched for the environment. A header the call wrote itself always wins over the default of that name, anddisabledDefaultHeaderson the payload refuses one outright.run_requestandstart_load_runtake that list as an argument (issue #1337), which is how an agent sends a request with noUser-Agentat all rather than one carrying a value it chose; it rides beside the composed payload, because the names say which of the engine's own defaults this send declines and composition neither reads nor rewrites them.run_collection_smokedoes not take it - it replays each saved request exactly as stored - and a scenario load run refuses it by name, as it does every other single-target field, because its steps are each composed from their own saved request. So the request an agent composed is not the request that was sent, and asserting on the composed one is how a correct request gets reported as wrong.requestHeadersin the result is the sent record - composed plus everything but theCookieline, minus aform-dataContent-Typelibcurl writes itself and minus any header whose value is empty, which libcurl reads as a removal rather than sending - andrawRequestis the full wire frame including theCookieline and libcurl's ownAccept/Content-Length. Both are passed through verbatim; read them rather than the request the call sent. ApostRequestScriptreads the same set aspm.request.headers(see scripting.md). - Transport fields -
httpVersionrides the inline overlay for both tools, andstart_load_runaddsfollowRedirects/maxRedirects(issue #760). They belong here rather than beside the load shape because they describe the request:POST /composeemits all three on a stored request under the never-elided rule, so an agent's value has to be laid over the composed payload the same way a URL override is. A URL-only run has no stored row behind it, which makes this the only way its redirect policy gets stated at all.verifySSLis the one transport field that deliberately does not ride the overlay:run_requestrefusesverifySSL: falseandstart_load_runtakes no such argument, because a certificate check skipped for one call is recorded nowhere afterwards. It is written onto the saved request instead (issue #795), and every by-id compose then carries it. - Streaming -
run_requesttakesstream: truefor atext/event-streamendpoint (issue #575).tools/callis request/response, so the tool does not stream to the agent: it starts the run, reads the relay for at moststreamBudgetMs(default 5000, maximum 60000) collecting at mostmaxStreamEvents(default 50), and returns those events with which bound it stopped at beside them -completed,capReachedorbudgetExhausted, plustotalEventswhere the completion frame reported it. The three are separate because the follow-up differs: a completed stream is finished, a capped read wants a larger cap, and an exhausted budget means the run is still going andstop_runends it. The flag is sent on every call, never elided and never inherited from a stored row - the two answers have different shapes, so the tool decides which one it is about to parse rather than a default deciding for it. The allowlist gate is unchanged: it runs on the composed URL before anything is sent. - Data rows -
run_requesttakes an optionaldataobject: one row, which binds every{{data.column}}in the URL, headers and body and which both scripts read aspm.iterationData(pm.info.iterationis0, issue #601). It rides beside the composed payload rather than through/compose, because{{data.*}}survives composition by design - that is what leaves the tokens for the engine to bind. A column the row does not carry is an error naming the token and the row's columns, and nothing is sent. Auth credentials bind as well (issue #642) - before they are encoded, so basic auth base64s the row's values - with OAuth 2.0 the one mode no row can reach, refused by name because its token comes from the token endpoint rather than from the request. The allowlist gate reads the composed URL as always - a{{data.*}}in the path leaves the host knowable and is judged on that host, while a template in the authority is still "unknown host" and denied.run_collection_smokestays out of it: it runs each request on its own, with no sequence to iterate, so there is no row for it to bind. A whole data set is a run's argument instead:scenario.dataonrun_collectionand onstart_load_run's scenario shape (below), orstart_load_run's top-leveldatafor a single target (issue #993) - the same rows, the same refusals, bound one per request the run sends off a cursor that wraps rather than one per iteration of a sequence. The two fields are mutually exclusive:databeside ascenarioblock is refused by name rather than dropped. - Protocol -
run_requestandstart_load_runboth take an optionalhttpVersionZod-enum arg ("auto" | "http1.1" | "http2", default"auto"), mirroring the request builder's Settings-tab picker.run_collection_smokehas no such arg: it replays each saved request exactly as-is, andPOST /composealways emits a stored request's protocol.start_load_runwith arequestIdlays a statedhttpVersionover the stored one through the compose body'srequestoverlay, like any other agent-stated field. On a URL-only call there is no saved row behind the request, sohttpVersionis forwarded only when the caller actually supplies it. - One post-response script, one name on the wire, not three. Before issue
#1514 it was stored as
postRequestScript(on a request and on a collection), sent aspostRequestScripts/postRequestScripttoPOST /execute, and asteststoPOST /runs- three spellingsread_post_request_scriptread on both routes. The cut-over ended all three: it is stored as ascript.postelement now (create_request/update_request/create_collection/update_collection'spreRequestScript/postRequestScriptsugar folds a string into one, issue #1517), and bothPOST /executeandPOST /runsrefusepostRequestScripts/postRequestScript/testsoutright since issue #1594 gave the single-target load path the same element pipeline the design send already had -elements(run_request) /requestElements(start_load_run) is each route's only script source now, andread_post_request_scriptitself is retired. MCP still shows the agent a single argument name,postRequestScriptwithtestsaccepted as an alias (see One validation script, one name above), and folds it into an ad-hoc element client-side before either route ever sees a legacy field. - Storing a request's elements -
create_requestandupdate_requesttakeelements(the whole list) pluspreRequestScript/postRequestScriptsugar (strings, optional) that fold a script into ascript.pre/script.postentry rather than being written to the engine under those two names, whichPOST /requests/PUT /requests/:idrefuse outright since issue #1514.update_requestmerge-patcheselementslike every other field when the caller sends it directly: leave it out and the stored list is kept. The script sugar merges at the entry level instead - passing a script alone reads the request's current elements first (GET /requests/:id), replaces or removes just that one kind, and writes the whole list back, so a script written this way never drops the request's other elements; an empty string clears the kind.elementsand the script sugar cannot both be given on one call - a caller who states the whole list has already said what belongs in it. Thetestsalias is deliberately absent here - it is the engine's spelling for an ad-hoc run body, and a stored field answering to two names is a second name to keep in step. Storing an element adds persistence, not execution capability: an agent could already run arbitrary scripts throughrun_request, and a stored one runs only when the request is later sent. Both tools already declareinvalidates: ["request"], so the renderer refetches the row and picks the elements up without a further entity. A collection's own elements - the ones that run around every request below it - are
create_collection/update_collection'selementsfield and the same two script names (issue #759), and carry the same merge and clearing rules. - Load-testing a saved request -
start_load_runwith arequestIdcomposes it by id, exactly asrun_collection_smokeand the app do: variables resolved, stored auth applied through the collection chain, and the chain's + its own elements attached underelements, which the tool renames ontorequestElements-POST /runsruns an element pipeline per submission on a single target too now (issue #1594), and reads its step-level elements under that key, distinct from the run-levelelementsoverride below. Any field stated explicitly (url, method, headers, body, auth, httpVersion) rides in the compose body'srequestoverlay and replaces the stored one before resolution; an explicitpostRequestScript/testsreplaces the composed chain'sscript.postelements inrequestElementsrather than joining them - with no way to know which assertions the agent meant, running both would add ones they never asked for. Without arequestIdthe run is ad-hoc andurlis required. A saved request's pre-request script runs only when marked inline (config.inline) or this run's ownelements.scriptsoverride is"allInline"- leftasMarkedwith no per-element mark, it still never runs, since there is no pre-request replay to defer it to the wayscript.postgets one. The count ofscript.preelements that will not run is reported in the tool's result rather than passing silently, unless the override already covers them. - Scenario runs - a collection as the unit of work (issue #754, reversing
#454's deferral). A collection's ordered sequence of requests can be run from
MCP in both of the engine's modes, over the one
POST /runsroute that takes ascenarioblock: run_collectionposts the block with nomode, which is what selects the design-mode runner: steps execute one at a time, share the environment's cookie jar, honourpm.executionflow control, run their stored pre-request scripts, and repeat once perdatarow with{{data.column}}bound andpm.iterationDataset. It returns the run id immediately - the run continues engine-side - along with the plan's step count and the note thatget_run_report'sresultscarries at most 100 step rows. Each of those rows carries that step's request and response bodies inline, undertrace(build_result_trace, the same node a single design-mode send stores) - not behindGET /runs/:id/samples, which is the load-run capture route - so a long plan against large responses makes for a large report. That is what the 96 KB total trace budget above bounds: past it a step row keeps its scalars and carriestraceOmittedinstead of its bodies, non-passing steps last to lose them.start_load_runtakes the same block as an optionalscenarioargument and posts it with a mode, which hands the plan to the load executor:concurrencyis the number of virtual users, each walking the whole plan with its own cookies. Onlyconstant_concurrency(the default),ramp_upanditerationscan drive a sequence -capacityandconstant_rpsare refused with the engine's own reasoning (a knee measured against which step's p99? an arrival-rate executor Vayu does not implement), as is any non-zerotargetRps, which selects that path whatever the declared mode.- The collection tree is the sequence: there is no step list to send, and
recursive: truewalks sub-collections in the sidebar's order (each subtree before that level's own requests, mirroringcollect_requests). - Rows are inline - the engine never opens a file - and its
maxScenarioDataRows/maxScenarioDataBytesrefusals are surfaced verbatim rather than re-derived in the schema. scenario.iterationsis offered onrun_collectiononly. A load run reads the top-leveliterations(total passes across all virtual users); the in-block count is the design runner's and the load executor never reads it, so offering it there would be an argument written and never read.failOnSchemaErrormakes the bound contract a gate (issue #766), onrun_collectiononly, matching the Run Collection dialog's checkbox. It is run-scoped and top-level, beside thescenarioblock rather than inside it, and is sent only when asked for: the engine defaults it to false, so a run snapshot carries the key exactly when it changed what "failed" meant. With it on, a step whose response does not match the schema its collection's bound document declares fails - but only a step that passed everything else; one already failing keeps the error that named it. Off, the verdict still rides every step and the report'sschemaValidationtotals. Note the default is the opposite ofrun_collection_smoke's, for the reason recorded there.start_load_runrefuses the flag on either of its paths, naming the executor: a load run validates sampled responses once the run has drained and never demotes a step, so the gate would decide nothing. It is declared on that tool for the refusal's sake - an argument the tool's schema does not name is stripped before the handler sees it, which would drop the flag in silence.elements: {timers, scripts}overrides a run's stored timer/script behaviour without editing the collection (issue #1559, exposing the block issue #1495 defines onPOST /runs,validate_elements_run_override).timers: "off"silences everytimer.*wait for the run;scripts: "allInline"/"allDeferred"forces everyscript.pre/script.postelement's inline-vs-deferred execution regardless of its own marking;"asConfigured"/"asMarked"(the defaults) leave each element's own configuration in effect. It is top-level, besidescenariorather than inside it, on both tools that read it - the same placeRunCollectionDialog's load-test section sends it.run_collectionofferselements: {timers}only, sincescriptshas no reader on the design-mode path it drives.start_load_runoffers both keys, on either shape it can start:timersfires on the scenario branch'sexecute_scenario_run(RunContext::timers_overridewired into the sameExchangeInputsa scenario load run does) and, since issue #1594, on a single target's own submission pipeline too (load_strategy.cpp's per-step hooks);scriptsreaches the scenario load path's post-run replay and, since #1594, a single target's own inline-vs-deferred dispatch (RunContext::script_element_runs_inline) - the mechanism that makes"allInline"the thing that gets a single target'sscript.preto actually run under load (see Load-testing a saved request above). This mirrorsRunCollectionDialog's own load-test controls, not the engine's full contract:timersalso accepts a{fixedMs}/{minMs, maxMs}override (issue #1498) replacing every timer's own span, and the block also takesincludeScriptTimeandseed, but no control anywhere in the product - app or MCP - sends any of the three today, so these schemas only name the two enum values"asConfigured"/"off"and"asMarked"/"allInline"/"allDeferred".- The allowlist gate is all-or-nothing here, unlike the smoke matrix's
per-request skip: every step is composed by id and gated before the run is
created, and one step the allowlist does not cover refuses the whole run
with nothing sent, naming the step the way the engine names it
(
step 1 (request 'checkout', id 'r2')). A step that will not compose refuses it too - the engine resolves the same plan before creating the run row and would refuse it for the same reason. - Every argument that describes a single target (
url,requestId,method,headers,body,auth,httpVersion,postRequestScript,collectionId) is refused by name beside ascenario, as aremaxInFlight(in-flight is bounded by the virtual-user count),sloMs/stepDuration(capacity's own fields) andstreamand its two caps (run-level stream bounds are attached to a single-target run's request only). Each would otherwise be an argument an agent believes shaped the run that nothing on this path reads. - Request mutation - a pre-request script's
pm.requestedits (url, method, headers, body) are applied to the request that is sent, so an agent can sign a request or override the engine-applied auth fromrun_request, and a saved request's stored pre-request script does the same underrun_collection_smoke. The write-back is engine-side (scripting.md), so both tools get it without composing anything extra. A rejected edit comes back aspreScriptErrorin the response.start_load_rundid not offer the field at all before issue #1594 -POST /runsran no pre-request hook on its single-target path. It still does not accept an ad-hocpreRequestScriptargument (a saved request's ownscript.preelement is the only source), and a composed one only mutates the request when it is marked to run inline or this run's ownelements.scriptsoverride forces it - see Load-testing a saved request above.
run_request / start_load_run take optional environmentId and
collectionId to scope resolution; both are forwarded to POST /compose.
An unknown requestId is the engine's definitive 404, surfaced as a readable
"no saved request with id" tool error.
Cookies are shared with the app, per environment. run_request and
run_collection_smoke go through POST /execute, which sends through the
engine's cookie jar (issue #301) - so a Set-Cookie an agent collects is sent
on its next call, and on the user's next Send in the same environment. That is
deliberate: the jar belongs to the environment, not to the caller, and giving
MCP a jar of its own would make the same saved request behave differently for
an agent than in the UI - a surprise in the harder direction to debug. The
state stays visible and resettable from either side: Settings → General →
Cookies lists every jar and clears it, and get_cookies / clear_cookies
(issue #758) read and clear the same jars over MCP - so an agent can see the
session it inherited and drop it rather than only being told it exists. An agent
that must not inherit one at all should run in an environment of its own. run_collection
shares the jar too - the design-mode runner is the one executor handed it, which
is what lets a login step authenticate the steps after it. start_load_run is
unaffected either way: a single-target load run never touches the jar, and a
scenario load run gives each virtual user cookies of its own.
History. Until issue #226 MCP carried
resolve.ts- a full main-process port of the renderer's composition pipeline - because the engine composed only partway. That copy (and itsdynamic-variables.tstwin) is deleted; the engine path is the single implementation, and the renderer's remaining resolver is preview-only. Do not reintroduce client-side composition here - a new engine client should callPOST /compose.
Variables¶
The resolution order is stated once rather than reworded per tool: globals < collection chain (root to leaf) < active environment, with a bound data row's bare column names above all three. That is the ladder Variable Resolution defines and the engine executes; before issue #1207 it lived only inside two tool descriptions that happened to mention it, and nowhere a caller about to write a value could ask for it directly.
The model itself is served once, as a resource
(vayu://variables/resolution, backed by variable-origins.ts's
VARIABLE_RESOLUTION_MODEL), rather than restated per tool. Every
variable-writing or -listing tool - get_globals, update_globals,
list_environments, create_environment, update_environment,
create_collection, update_collection - carries one shared sentence
(VARIABLE_PRECEDENCE_SENTENCE) plus that tier's own position in the order and
a pointer to the resource; precedenceNote() builds both pieces from one
place. A table-driven scan in tools.test.ts runs over every variable tool's
description and fails on one that omits the sentence, so a new variable tool
cannot ship silent about where its tier sits.
resolve_variables answers what the per-tier reads cannot: which
definition of a name actually wins in a given collection/environment context,
and everything it beat. It reports the winning value with its scope and
source, then every other definition highest-precedence-first, each labelled
outranked (a higher-ranked definition - a later link in the chain, or a
tier above - is enabled and wins instead) or disabled (enabled: false).
The second is the more common answer to "why is this not the value I set?",
and a list built by skipping disabled rows could not give it. A name whose
every definition is disabled resolves to nothing at all, not to an empty
string: the tool reports resolved: false with no value, because a
present-and-empty answer would read as a different fact.
Secret values are withheld from resolve_variables's report
(valueWithheld: true) to match the app's variable popover. That withholding
is consistency with the app, not a security boundary: list_environments,
get_globals and vayu://environments still return every value in full -
the same recorded pre-1.0 item the app-side-masking note above (under
update_environment) already names.
The model is duplicated on purpose, and pinned so the copies cannot drift.
Neither process can import the other's resolver: the main process emits with
rootDir: "electron" and cannot compile a file under src/ (TS6059), and the
renderer cannot import from here, because this is a referenced composite
project (TS6305). So electron/mcp/variable-origins.ts mirrors
app/src/lib/variable-resolution.ts the way compare.ts mirrors
src/lib/run-compare.ts (see the file map below), and
variable-origins.conformance.test.ts replays the engine's own fixture
(engine/tests/fixtures/variable-resolution-conformance.json) through both, so
a divergence fails a test rather than surfacing as a wrong answer months
later. The fixture supplies a pre-flattened chain, so it cannot exercise the
parentId walk; that half is pinned separately, by running collectionChain()
and the renderer's walkAncestors() over the same rows - including the
malformed ones (a self-parent, a two-node loop, a parent that does not exist) -
and asserting they agree.
Call this what it is: a second implementation of the resolution order, kept
honest rather than avoided. Importing the renderer's resolver is the option
that would have avoided it and the build forbids it; an engine endpoint
reporting origins is the other, and it would mean new C++ for a question the
engine has no use for (execution needs the winner, never the losers). This
does not make MCP a second composition path, which is the split that is
load-bearing: resolve_variables reports stored definitions and substitutes no
{{tokens}} - POST /compose remains the only place a request is composed.
Resources¶
Read-only Vayu data an agent can attach as context (resources.ts):
| URI | Contents |
|---|---|
vayu://runs |
The most recent 100 runs (first page), newest first; pagination.total / hasMore in the content carry the full count. A resource takes no arguments, so filtering and paging beyond this page is the list_runs tool's job. |
vayu://collections |
All request collections. |
vayu://environments |
All environments. |
vayu://variables/resolution |
The resolution rule set: tier order, disabled/non-string handling, reserved namespaces, and what a script's scoped and merged reads see. See Variables. |
vayu://config |
Engine configuration entries. |
vayu://scripting/completions |
The script sandbox's full API surface (see below). |
vayu://scripting/types |
The same surface as TypeScript declarations - the .d.ts the app's editor loads, so a call's parameters and return type are the running engine's. |
vayu://elements/kinds |
The element registry's catalogue (see below): every kind's category, phases, hot-path class and config JSON Schema. |
vayu://run/{runId}/report |
A run's full report (templated). |
The templated report resource has a list callback (enumerates recent runs so
each shows in resources/list) and a completion callback (autocompletes run
IDs).
The script sandbox surface¶
preRequestScript and postRequestScript run in the engine's QuickJS sandbox,
which is the same sandbox the app's editors target. There is exactly one
per-client capability gate - pm.sendRequest, below - and an agent can do
anything else a script in the app can. What an agent lacked was any way to
know that: until issue #233 the entire script surface it could see was the two
sentences in those fields' descriptions, so pm.expect chains,
pm.response.to.*, the variable scopes and pm.crypto were invisible and
simply never attempted.
Two resources, because a name and a signature are different questions.
vayu://scripting/completions re-serves GET /scripting/completions (Monaco's
own fields projected away) and answers what exists;
vayu://scripting/types re-serves GET /scripting/types, the .d.ts the
engine generates from the same table, and answers what it takes and returns
(issue #760). Both throw rather than serve a partial surface - an agent reading
a truncated API list concludes the sandbox cannot do what it can - and neither
holds a copy of the surface here, which is the whole point: the app's own
quick-reference panel had gone stale before #233 and this is what replaced it.
The element catalogue¶
vayu://elements/kinds re-serves GET /elements/kinds, the element
registry's catalogue (issue #1513): every kind's category, the phases it
runs at, its hotPathClass and its configSchema - the same shape the app's
generated element forms read (issue #1516). create_request / update_request
/ create_collection / update_collection's elements argument and
run_request's ad-hoc elements argument both name a kind as a bare
string rather than an enumerated one, precisely so a kind the engine adds
later needs no MCP change - read this resource rather than guessing a kind or
its config shape. element-kinds.conformance.test.ts pins the resource, the
schema's own examples and every kind literal a tool description names to the
engine's own fixture, so a kind renamed engine-side without a matching update
here fails the build rather than telling an agent to write a kind that no
longer exists.
pm.sendRequest is refused for MCP-started runs¶
The sandbox can send an auxiliary request (issue #302). That would be a hole in the allowlist if it applied here, and the reason is structural rather than an oversight: the allowlist is checked in this server, against the composed URL, before it calls the engine. A request issued from inside a script never goes through the MCP server at all, so it could never be checked - an agent that can write a script would otherwise reach any host, defeating a control the user set in Settings.
Three ways to close that were available, and this is which one and why:
- Move the allowlist engine-side for script-issued requests. Rejected. The
allowlist lives in the app's config, and the engine has no channel to it; the
engine would need a second implementation of host matching alongside
safety.ts's - two copies of a security check, which drift. It would also leave the engine enforcing an allowlist for one kind of request and not the others, which is harder to reason about than either extreme. - Gate the feature off by default behind a setting. Rejected. It adds a knob whose safe position is the only correct one for MCP, and the hole reopens the moment anyone flips it for an unrelated reason.
- Refuse script-issued requests unless the caller explicitly asks. Chosen.
The engine denies
pm.sendRequestunless the execute/run payload carriesallowScriptRequests: true. The app's own Send and load runs ask for it, each in the one service method that owns that call (apiService.executeRequestandexecuteStreamRequest- Send's two halves - plusstartLoadTest/startScenarioRun); this server never does. The allowlist stays exactly where the user configured it, and the engine gains a capability bit rather than a policy copy.
Denying by default is the load-bearing half: a new tool here, or any future
engine client, gets a script that cannot send rather than unchecked egress.
An agent cannot set the field either - every tool builds its request from named
arguments, so an allowScriptRequests in the arguments is dropped before
composition, and Zod strips what a tool does not declare.
A script that calls pm.sendRequest under MCP throws a message saying why, so
an agent is told rather than left with a silently missing global. The
completion entry states the same thing, so the surface an agent reads and the
surface it gets agree.
vayu://scripting/completions closes that. It re-serves the engine's own
GET /scripting/completions - the single source of truth that also feeds Monaco,
generated from one table in engine/src/http/routes/scripting.cpp and
cross-checked against the runtime by script_completions_test.cpp. Notably it
carries pm.crypto.sha256 / .hmacSha256 and the btoa / atob globals, and
their documentation states they are synchronous (the sandbox has no event
loop, so nothing Promise-based would ever settle).
Each entry is trimmed to label, detail and documentation; Monaco's own
insertText, insertTextRules, sortText, filterText and kind are dropped,
since snippet placeholders and a CompletionItemKind enum mean nothing outside
an editor. The trim is the only transformation - no list of pm.* names is
maintained app-side, which is the point: a name the engine adds reaches agents
with no second edit, and resources.test.ts fails if the resource ever answers
from a local literal instead of the engine. The tool descriptions carry one
sentence pointing here, for an agent that never lists resources.
Prompts¶
Server-provided starting points a user picks in their client (prompts.ts):
| Prompt | Arguments | Produces |
|---|---|---|
summarize_run |
runId |
The run report + a "summarize p50/p95/p99, errors, health". |
compare_runs |
baseRunId?, targetRunId |
The computed delta + "did this regress?". An omitted baseRunId resolves the target's pinned baseline through the same resolveBaseline the tool uses - the prompt demanded an id Vayu already knew (#760). |
diagnose_errors |
runId |
The report + an error-focused diagnosis prompt. |
suggest_load_profile |
url, goal? |
Guidance to design a start_load_run (no engine data). |
Safety model¶
Enforced in the MCP layer (safety.ts, config.ts), with one exception noted
below: script-issued requests are refused engine-side, because this layer cannot
see them. Nothing here changes engine behaviour for other clients. All
configurable in Settings → MCP and persisted.
- Target allowlist (default empty ⇒ deny all). Network-touching tools refuse
off-list hosts with an actionable error. An "Allow all hosts" opt-in
bypasses the list (still rejects unresolved
{{variables}}); off by default. Entries are hostnames, matched exactly: what you type in Settings is reduced to a host (https://api.example.com:8080/v1andapi.example.comare the same entry), and the request URL is matched by its host whether or not it carries a scheme (localhost:3000/apimatches the entrylocalhost). An IPv6 target is stored and shown in its canonical bracketed form - typing::1stores[::1], which is what a URL parses to. A run'smonitor.urlis a second host and gets its own check, with one deliberate exemption: a loopback or private-network monitor endpoint (localhost,127.0.0.0/8,10/8,172.16/12,192.168/16,169.254/16,::1,fc00::/7,fe80::/10) needs no allowlist entry, while a public one is checked exactly as the target URL is. The allowlist exists to stop an agent generating traffic against third parties it was never pointed at, and a private address is by definition the user's own network - which is also the feature's own case, since the endpoint a load run wants beside it is the target's ownlocalhost:9100. The test is textual, like the allowlist itself: a DNS name that resolves to a private address still needs an entry, because resolving it here would make the answer depend on the network it was asked on. Because the check happens here, before the engine is called, a request sent from inside a script could not be checked at all - sopm.sendRequestis refused outright for runs this server starts. See The script sandbox surface. - Hard caps - max RPS / concurrency / duration / iterations on
start_load_run; over-cap requests are rejected. With the allowlist, these are the real limits on load, and they cover every field the tool forwards:concurrencyandstartConcurrencyare both held to the concurrency cap. A ramp is seeded withstartConcurrencybefore its first duration check, so capping only the target would bound where a run ends and not where it starts.- An iterations run stops on a request count and never reads
duration, so no duration cap can bound it. Max iterations is its own setting for that reason (10000 by default). An omittediterationsis compared as the engine's own default of 1000, and an unrecognisedmodecarrying aniterationsfield is capped the same way, because the engine runs that as an iterations run too. - An omitted
durationis 60s engine-side, not "unbounded" and not "capped". WhenmaxDurationSecondsis under 60, the tool sends the cap as an explicit duration so the run is actually bounded by it.concurrency,startConcurrency,iterationsandmaxInFlightare additionally constrained to positive integers by the tool's own schema, because "unlimited" is an obvious guess to spell-1or0and the engine reads them as an eager per-worker pre-allocation count, a ramp seed, a request budget, and an in-flight ceiling (see the accepted ranges under POST /runs).maxInFlightis the one that bounds work downward, so there is no separate cap setting for it - the enormous value is the one that removes the backpressure the caller asked for; its schema additionally carries the engine's own ceiling of1000000, so a value this tool accepts is onePOST /runsaccepts.duration/rampUpDurationare also rejected when they are not durations at all (ms/s/m/h, or a bare number of seconds - the same grammar the engine parses), since the engine now fails such a run rather than quietly substituting 60s; a zerodurationis rejected here for the same reason the engine400s it, while a zerorampUpDurationstays legal (an instant ramp).
- Confirmation - anti-accident, not anti-adversary: it stops a stray tool
call from starting load or destroying saved work, but on HTTP it is agent-side
(the caps/allowlist are the enforcement). Elicitation upgrades it to a human
prompt where supported. Eight tools carry it -
start_load_run,delete_collection,delete_request,delete_request_example,delete_run,delete_environment,delete_webhook_inboxandpurge_trash_entry- through one implementation, so the elicitation path cannot drift between them. A preview is a successful result that deliberately did nothing, so it emits nomcp:data-changedevent either.restore_trash_entrydeliberately does not carry it: it puts data back rather than destroying it, so the write toggle alone is its gate (below). - Write toggle (
allowWrites, default off) - gates every tool in the write category, and while it is off they are not registered at all: they are absent fromtools/list, not merely refused bytools/call. A call on one of their names anyway - guessed, or held over from a list fetched while the toggle was on - is answered with the handlers' own refusal, which names the setting to turn on; the SDK would otherwise reject the unregistered name withTool <name> not found, which tells an agent nothing it can act on. The server instructions say the same thing up front, in the sessions where the toggle is off. Withholding the schema rather than only the call is what makes the toggle free: those tools are ~40% of the tool payload an agent is sent, and on a default install none of them could have succeeded. Because the tool set is recomputed per built server (a fresh one per HTTP request), flipping the toggle takes effect on the client's nexttools/list- the same timing the per-tool switch below already has. The category covers:create_collection,update_collection,delete_collection,create_request,update_request,delete_request,create_request_example,update_request_example,delete_request_example,move_item,create_environment,update_environment,activate_environment,delete_environment,update_globals,clear_cookies,update_engine_config,set_run_baseline,delete_run,delete_webhook_inbox,clear_inbox_captures,restore_trash_entry,purge_trash_entry. Does not gaterun_request/run_collection_smoke/ load runs (allowlist + caps). Seven of those need the toggle and confirmation - the six deletes pluspurge_trash_entry: the toggle is a single session-wide switch a user flips once to let an agent save a request, which is not consent to destroy a subtree, a run's stored history, or a trashed row for good.clear_cookiesandrestore_trash_entrytake the toggle without a confirmation, for opposite reasons: one ends a session rather than anything saved, the other puts a row back rather than destroying one. - Loopback services carry no gate of their own -
start_mock_issuer,stop_mock_issuer,update_mock_issuer,start_mock_server,stop_mock_server,start_webhook_inbox,stop_webhook_inboxandupdate_inbox_responseareexecutetools that neither the allowlist nor the write toggle governs. (The two that destroy recorded data,delete_webhook_inboxandclear_inbox_captures, arewritetools and do take the toggle - what they end is not the listener but the captures.) The allowlist exists to stop an agent generating traffic against third parties it was never pointed at, and a mock issuer is bound to127.0.0.1by the engine with no host to configure - as is a mock server, and as is an inbox, whosebindthese tools never send; the write toggle gates saved data, which an ephemeral listener is not (a mock server only reads the examples it serves). Nor would a gate here withhold anything: an agent withlocalhostallowlisted can already reachPOST /mock-issuer/startthroughrun_request, for the same reason the endpoint needs no auth token. The bounds that do apply are the engine's own - at most 8 issuers at once - and the per-tool switch below. - Per-tool control - any tool or whole read/execute/write/load category can be
switched off; a disabled tool is omitted from
tools/list, and calling its name is answered withTool <name> not found- it is unregistered, so the SDK rejects the name before any Vayu code runs. (The write toggle's refusal above is the deliberate exception, and it does not claim a tool the user switched off: that tool would still be off after enabling writes. The rejection indispatchToolis what any other caller of it gets.) Settings lists every tool whatever the write toggle says, so the switches for write tools stay visible and settable while writes are off - the toggle governs what the agent is offered, not what the user can configure. This and the write toggle are independent, and a write tool needs both: switching the write category on here does nothing whileallowWritesis off, and turningallowWriteson re-enables no tool that is indisabledTools. Settings states this on both cards, because a user who flips one switch and sees no change has nothing else to go on. - Server on/off - the whole server can be disabled; while off the endpoint does not accept connections. Persists across restarts.
- Transport hardening - loopback bind, Host-header (DNS-rebinding) validation,
POST-only, 4 MB body cap.
Why no auth token on the endpoint: any local process could already reach the
engine's REST API on :9876; the MCP endpoint proxies the same capability behind
more guards and adds DNS-rebinding protection. It grants no capability a local
process did not already have.
Safety config¶
McpSafetyConfig (defaults in parentheses):
| Field | Default | Ceiling | Meaning |
|---|---|---|---|
allowlist |
[] |
- | Permitted hostnames (empty = deny all). |
allowAll |
false |
- | Bypass the allowlist for any resolvable host. |
maxRps |
1000 |
1000000 |
Cap on targetRps, which only constant_rps carries. |
maxConcurrency |
200 |
10000 |
Cap on concurrency and startConcurrency (closed-loop). |
maxDurationSeconds |
300 |
86400 |
Cap on load-run duration. |
maxIterations |
10000 |
100000000 |
Cap on iterations (iterations mode). |
allowWrites |
false |
- | Enable the data-mutating tools. |
disabledTools |
[] |
- | Tool names to hide/reject. |
The renderer never sets these directly: main.ts sanitizes every change
(sanitizeSafetyInput - normalizes/de-dupes hosts, holds each cap to a whole
number between 1 and its ceiling, trims and de-dupes disabledTools) before
applying it live and writing it to disk. The same sanitizer runs over the
persisted file on load and over the CLI's VAYU_MCP_* variables, so no path
reaches the guards with a cap outside that range.
The ceilings are MCP_CAP_CEILINGS, mirroring the renderer's
LOAD_TEST_CEILING_BOUNDS maxima - the engine's own guards where it has one
(concurrency at 10x event_loop::MAX_CONCURRENT, durationSeconds at the
per-transfer timeout guard). A cap set above its ceiling is held there rather
than stored: the value it would admit is one the engine refuses or one no Vayu
surface will compose, so storing it shows a guardrail that does not exist. The
copy is tied to the renderer constant by config.test.ts, the way
MAX_IN_FLIGHT_BOUND is - electron/ may not import src/.
maxRps and maxConcurrency bound different runs, which is why neither is a
general "load cap": targetRps exists only in constant_rps, so maxRps is
inert against a closed-loop run, and maxConcurrency bounds the concurrency a
closed-loop run holds (or a ramp starts from, or a capacity search climbs to) -
not the in-flight requests of a rate-paced run, which maxInFlight governs.
Architecture¶
Everything lives under app/electron/mcp/ and is managed by main.ts alongside
EngineSidecar.
| File | Responsibility |
|---|---|
config.ts |
McpSafetyConfig, safe defaults, input sanitizer, host normalizer. |
safety.ts |
Pure guards: allowlist, load caps, duration parsing. |
engine-client.ts |
Thin fetch client to the engine REST API + SSE metrics snapshot. |
compare.ts |
Pure two-report diff for compare_runs. Mirrored by the renderer's src/lib/run-compare.ts (neither process can import the other's source); compare.conformance.test.ts fails on any divergence. Reads the status mix in both wire shapes: the renderer's transformed record and the engine's own array of [code, count] pairs (std::map<int, size_t> cannot serialize as a JSON object), which is what this path gets from a raw GET /runs/:id/report. |
variable-origins.ts |
The resolution model the vayu://variables/resolution resource serves, and the winner-plus-shadowed computation behind resolve_variables. Mirrors the renderer's src/lib/variable-resolution.ts and useVariableResolver.ts for the same reason compare.ts does; variable-origins.conformance.test.ts replays the engine's fixture through both and fails on divergence. Resolves no {{tokens}} - see Variables. |
http-versions.ts |
The httpVersion value list the Zod schemas enumerate. |
tools.ts |
Tool registry (schemas, annotations, handlers) + dispatchTool, the one dispatch path server.ts and the tests share. |
resources.ts |
Static + templated resource definitions. |
prompts.ts |
Prompt definitions (build messages from engine data). |
server.ts |
Builds the SDK McpServer; registers tools/resources/prompts. |
http.ts |
Stateless Streamable HTTP host (DNS-rebinding on). |
cli.ts |
Standalone stdio server (env-configured). |
connect.ts |
One-click connect: resolves and runs the claude / code CLIs. |
store.ts |
Persist safety config + enabled preference (electron-store). |
index.ts |
VayuMcpService facade consumed by main.ts, loaded on demand. |
Lifecycle & IPC¶
main.ts starts the server in app.whenReady() (skipped if disabled), stops it
on quit, and exposes IPC the Settings panel uses.
main.ts imports this directory by weight. config.ts, store.ts and
connect.ts are self-contained (electron-store and node:child_process are
their heaviest dependencies), so they are ordinary static imports. Everything
reachable from index.ts - the SDK, zod, tools.ts and its 67 schemas built at
module scope - is loaded by a cached dynamic import() instead, inside
startMcp() after the enabled check and inside the two IPC handlers that need
the tool catalog. The main process is unbundled, so a static import here is
evaluated before app.whenReady and cost every launch ~250-300 ms ahead of the
window, MCP switched off or not (#1145). A disabled launch now evaluates none of
it; opening the Settings tool list is what pays for the registry.
The IPC surface:
| IPC handler | Purpose |
|---|---|
mcp:status |
{ running, url, enabled }. |
mcp:getSafety |
Live McpSafetyConfig, or the persisted one when off. |
mcp:updateSafety |
Sanitize, apply live, persist; returns the resolved config. |
mcp:setEnabled |
Start/stop the server, persist the preference. |
mcp:getTools |
IPC-safe tool catalog (name/description/category). |
mcp:connectClient |
Run a client's add-CLI (claude / code). |
One channel runs the other way, main → renderer:
| Channel | Purpose |
|---|---|
mcp:data-changed |
A successful call changed engine data; invalidate its queries. |
The UI reflects MCP writes live. An MCP call mutates the engine from the
main process, which no renderer query can observe (refetchOnWindowFocus is off
app-wide), so a request an agent created used to stay invisible in the
collection tree until some unrelated mutation happened to refetch the lists.
Each tool declares the data families it changes (invalidates in tools.ts)
and dispatchTool - the single dispatch path - sends one mcp:data-changed per
family after a call that did not return an error. The event names a family
(collection, request, environment, run, cookie, config, service,
oauth)
plus the collectionId / requestId / runId / inboxId / mockId the call
itself named; it carries no engine data, so the
renderer still reads every row through its query layer. The five hints are read
off the call's own arguments at the dispatch chokepoint, which is what keeps a
new write tool from having to remember an emit of its own - every tool in the
registry spells them the same way. They are hints, not identity: requestId on
a run event is the saved request a design run was linked to, while runId is
the run itself, and only the tools that rewrite or remove an existing run
(stop_run, set_run_baseline, delete_run) name one - a runner's new run has
no per-run cache to drop yet. inboxId and mockId are the same shape for the
service family: the tools that act on an existing listener name one, and
start_webhook_inbox / start_mock_server cannot, since the engine assigns the
id. Both exist because their per-id cache has to be dropped rather than
refetched - a cleared capture list would union its destroyed rows straight back,
and a stopped mock's route table has no live id left to refetch from. service
is deliberately one family covering inboxes, mock servers and issuers, because
the surfaces that read them - the Services drawer and the Dock's
running-services count - ask "what is listening" rather than "which kind".
oauth (issue #760) carries no hint of its own even though a cache key
exists: an agent names the key it clears, but the key a fetch_oauth2_token
writes under is derived engine-side and appears only in the answer, so a hint
would be present for one tool and absent for the other - the shape that leaves
a stale row exactly when it matters. The family is invalidated at its prefix
instead.
One field on the event is not a hint at all. startedRun rides the run
event of the two tools that create a run - start_load_run and
run_collection - and says that the run is live, naming it and which of the
renderer's two run services owns its stream ({runId, kind: "load" |
"collection"}, issue #1419). It is read from the engine's 202 answer rather
than from the call's arguments, because a run has no id until the engine has
given it one, and an answer carrying no runId announces no run. Everything a
run does in the background - the taskbar/Dock progress bar, the keep-awake hold
and the finished notification - lives in those services and begins when one is
told to watch a run, and before this field existed only a renderer surface ever
told them: a run an agent started painted nothing and said nothing until its
dashboard tab was opened. useRunWatchers reads the field and enters the same
startMonitoring path the dashboard does, so the main process still tracks no
runs of its own. run_collection_smoke sends its requests one at a time through
POST /execute and has no run to watch, so its run event carries nothing
extra.
The renderer side, including which query keys each family maps to, is in
docs/app/state-management.md.
Declaring the families per tool rather than deriving them from category is
deliberate: an execute tool writes a run row and refills the cookie jar
without being a "write", and the field is required, so a new tool cannot ship
silently invisible to the UI.
The panel (app/src/modules/settings/main/panels/McpSettingsPanel.tsx) is a
registered app-settings panel; it talks to window.electronAPI directly since
MCP config is app-level, not engine-level.
mcp:getSafety answers from the persisted config whenever the server is not
running (switched off, or a failed port bind) rather than from the defaults, and
the panel shows an error with a Retry instead of substituting defaults when a
call fails. Both exist for the same reason: each control commits a whole field
computed from what is displayed - adding a host persists the displayed allowlist
plus the new entry - so a placeholder shown here would be written over the real
config by the very next edit.
Configuration¶
The Electron-hosted server reads config from Settings. The stdio CLI reads it from environment variables:
| Variable | Default | Meaning |
|---|---|---|
VAYU_ENGINE_URL |
http://127.0.0.1:9876 |
Engine base URL. |
VAYU_VERSION |
0.0.0 |
Version reported to clients. |
VAYU_MCP_ALLOWLIST |
(empty) | Comma-separated hostnames. |
VAYU_MCP_ALLOW_ALL |
false |
true bypasses the allowlist. |
VAYU_MCP_MAX_RPS |
1000 |
RPS cap. |
VAYU_MCP_MAX_CONCURRENCY |
200 |
Concurrency cap. |
VAYU_MCP_MAX_DURATION_SECONDS |
300 |
Duration cap. |
VAYU_MCP_MAX_ITERATIONS |
10000 |
Iterations cap (iterations mode). |
VAYU_MCP_ALLOW_WRITES |
false |
true enables the data-write tools. |
VAYU_MCP_DISABLED_TOOLS |
(empty) | Comma-separated tool names to disable. |
VAYU_LOG_DIR |
(unset) | Also write mcp_<stamp>.log there (#1558). |
Both entry points sanitize their input through the same function
(sanitizeSafetyInput in electron/mcp/config.ts), so the environment is held to
exactly the rules Settings is held to:
- A malformed cap falls back to its default, never to "no cap".
VAYU_MCP_MAX_RPS="1,000"is not a number, so the1000default applies and still refuses an over-cap run. The CLI names what it dropped on stderr:[vayu-mcp] ignoring malformed VAYU_MCP_MAX_RPS="1,000" (using default 1000). Non-positive values (0,-5) are treated the same way; fractional values are floored. - A cap above its ceiling is named too, through its own channel. It did not
fall back - it is in force, held at the ceiling - so the CLI says which value
actually applies rather than which default did:
[vayu-mcp] VAYU_MCP_MAX_CONCURRENCY="50000" is above the maximum of 10000; running with 10000. Without it the operator believes the policy is 50000 and learns otherwise from a refused run. Flooring alone is not reported:999.7becomes999and names no maximum, because it came near none. - Allowlist entries are reduced to a bare hostname, so
https://api.example.comandapi.example.com:8080both match theapi.example.comthe guard compares against. Entries are de-duplicated. - The two opt-in booleans stay off for any value other than the exact string
true.
Design notes¶
Rationale behind the load-bearing decisions.
TypeScript sidecar over in-engine C++¶
MCP could have been hosted inside the C++ engine (zero hop, single binary). The deciding factor: there is no official C++ SDK, so in-engine would mean owning the protocol in C++ or betting on a pre-1.0 community lib - right as the spec churns (the 2026-07-28 RC removed the GET stream endpoint and protocol-level sessions). The official TS SDK absorbs that churn, Node is already the Electron runtime, and the engine stays untouched (keeping it AGPL-clean). The accepted tradeoff: MCP is up when the app is open, not engine-only - covered later by the stdio CLI for headless use.
Stateless HTTP, and the server→client push gap¶
The HTTP host is stateless (fresh server per request), which keeps Settings
changes live for free and aligns with the spec RC that removed protocol sessions
and the GET stream. The cost is that tools/list_changed and elicitation can't be
pushed over HTTP (no held-open stream), so they fall back as described in
Transports. Making them live would require a
stateful server (real sessionIdGenerator, SSE responses, a GET stream per
session, persistent per-session servers mutated on toggle) - deferred, since it
builds on the mechanism the spec is deprecating and the payoff is client-dependent.
Deferred¶
- MCP-originated run tagging - tag runs started via MCP so History shows provenance.
vayu mcpbin - package the stdio CLI as a first-class command (#693).- Live push over HTTP - stateful sessions (see Design notes).
- Hosted MCP for Vayu Cloud - OAuth-gated, remote.
References¶
- MCP TypeScript SDK · Streamable HTTP transport
- Client docs: Claude Code · Codex · Cursor
- Engine API surface:
api-reference.md· Threat model:SECURITY.md