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). - 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.
# 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)¶
http.ts hosts the endpoint on 127.0.0.1:9877/mcp. It is stateless: each
POST /mcp gets a fresh SDK server + transport (sessionIdGenerator: undefined,
enableJsonResponse: true); GET/DELETE return 405; non-/mcp paths 404.
DNS-rebinding protection is on (Host must be 127.0.0.1:9877 / localhost:9877).
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.
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 (sends real traffic to a target - allowlist), 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=100 |
First page (100) of the {data, pagination} envelope; rows carry a compact summary |
get_run_report |
read | GET /runs/:id/report |
- |
get_engine_config |
read | GET /config |
- |
get_live_metrics |
read | SSE snapshot of last N ticks | - |
compare_runs |
read | 2× GET /runs/:id/report → diff (structured) |
- |
run_request |
execute | POST /execute |
allowlist |
run_collection_smoke |
execute | GET /requests?… + POST /execute (×N) |
allowlist per host |
create_request |
write | POST /requests |
write toggle |
update_environment |
write | GET+PUT /environments/:id (fetch-merge) |
write toggle |
update_engine_config |
write | POST /config |
write toggle |
start_load_run |
load | POST /runs |
allowlist + caps + confirm |
stop_run |
load | POST /runs/:id/stop |
- |
Notes:
start_load_runrequires confirmation - via elicitation when the client supports it, otherwise aconfirmed: trueflag - and enforces the RPS / concurrency / duration caps.get_live_metricsis a bounded snapshot (SSE read with a time budget), not a stream -tools/callstays request/response.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 (the engine marks these in each entry's 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.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. It is aPUT, not aPOST: since #95 the engine'sPOST /environmentsis create-only and would answer an existing id with a409.create_requeststays aPOSTfor the same reason - it creates, and lets the engine assign the id.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). 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.- Cancellation: each tool call's
AbortSignalis threaded into the enginefetch, so a client cancelling an in-flight call actually aborts it.
Request composition¶
In Vayu the renderer - not the engine - prepares a request before sending
it: it resolves {{variables}} and walks the collection ancestor chain to
resolve inherit auth. The engine applies a concrete auth block (bearer /
basic / apikey / oauth2, incl. its token cache); it performs no {{var}}
interpolation and drops {"mode":"inherit"} as "resolved app-side"
(auth_resolver.cpp::parse_auth).
Scripts are handled differently: both clients collect the collection-chain
pre/post scripts (root→leaf) and the request's own as an ordered list of parts,
each naming where it came from ({ origin, id, name?, script }), and send the
list as preRequestScripts / postRequestScripts on POST /execute - the
engine joins the parts with "\n\n" and runs the result (parts whose
script is blank are dropped). The renderer's load path builds the same kind of
list for its tests field on POST /runs; MCP's only POST /runs caller
(start_load_run) has no collection to chain-compose from - it forwards an
agent-supplied ad-hoc tests string as-is, the same as its ad-hoc
preRequestScript/postRequestScript (tools.ts::buildExecutionPayload), not
a chain-built list. Composing the content of a script list is engine-side
now; building the ordered list is still client-side, so it still needs both
clients to agree.
Because MCP talks to the engine directly, it must do that preparation itself.
resolve.ts is the main-process port of the renderer pipeline
(useVariableResolver.ts + request-builder/index.tsx + auth-resolution.ts +
utils/script-parts.ts) and is applied so a tool call behaves like the app
clicking Send:
- Variables - resolved in URL, headers, and body with the app's precedence (environment > collection chain, leaf→root > globals; enabled only; unknown → empty string). The allowlist is checked against the resolved host.
- Auth -
run_collection_smokeapplies each saved request's stored auth (inheritresolves against the collection chain);run_request/start_load_runaccept an explicitauthblock. In all cases variables inside the block are resolved and the engine applies it (oauth2 uses its token cache). - Scripts -
run_collection_smokecollects the collection-chain pre/post script parts (root→leaf) and the request's own, and sends the list for the engine to join and run, so a request's tests and setup actually execute.
Resolution only fetches the variable sources when a call needs them (a field
carries a {{template}}, or auth is inherit); a fully-literal call skips the
extra round-trips. run_request / start_load_run take optional environmentId
and collectionId to scope resolution.
Known duplication (deferred).
resolve.tsis a deliberate port of the renderer's composition pipeline, so the same semantics now live in two places (renderer + MCP). This is because the engine only does composition partway (it loads variables, applies auth, and joins/runs script parts, but does not interpolate{{var}}, resolveinherit, or build the ordered script-part list). The intended fix is to finish composition in the engine and let clients go thin. Until then, keep the two copies in sync and don't add a third. Seedocs/plans/pending-backlog.md→ A1.
Resources¶
Read-only Vayu data an agent can attach as context (resources.ts):
| URI | Contents |
|---|---|
vayu://runs |
Recent runs (first page, 100), newest first. |
vayu://collections |
All request collections. |
vayu://environments |
All environments. |
vayu://config |
Engine configuration entries. |
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).
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?". |
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 entirely in the MCP layer (safety.ts, config.ts); the engine is
never modified. 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. - Hard caps - max RPS / concurrency / duration on
start_load_run; over-cap requests are rejected. With the allowlist, these are the real limits on load. - Load-run confirmation - anti-accident, not anti-adversary: it stops a stray tool call from starting load, but on HTTP it is agent-side (the caps/allowlist are the enforcement). Elicitation upgrades it to a human prompt where supported.
- Write toggle (
allowWrites, default off) - gates the data-mutating toolscreate_request,update_environment,update_engine_config. Does not gaterun_request/run_collection_smoke/ load runs (allowlist + caps). - Per-tool control - any tool or whole read/execute/write/load category can be
switched off; a disabled tool is omitted from
tools/listand rejected bytools/call. - 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 | Meaning |
|---|---|---|
allowlist |
[] |
Permitted hostnames (empty = deny all). |
allowAll |
false |
Bypass the allowlist for any resolvable host. |
maxRps |
1000 |
Cap on targetRps. |
maxConcurrency |
200 |
Cap on concurrency. |
maxDurationSeconds |
300 |
Cap on load-run duration. |
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, clamps caps to positive
integers, validates disabledTools against the tool catalog) before applying it
live and writing it to disk.
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. |
resolve.ts |
Request composition: {{var}} resolution, inherit-auth, script-part lists. |
tools.ts |
Tool registry (schemas, annotations, handlers) + dispatchTool. |
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: shells out to claude / code CLIs. |
store.ts |
Persist safety config + enabled preference (electron-store). |
index.ts |
VayuMcpService facade consumed by main.ts. |
Lifecycle & IPC¶
main.ts starts the server in app.whenReady() (skipped if disabled), stops it
on quit, and exposes IPC the Settings panel uses:
| IPC handler | Purpose |
|---|---|
mcp:status |
{ running, url, enabled }. |
mcp:getSafety |
Current McpSafetyConfig. |
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/readOnly). |
mcp:connectClient |
Run a client's add-CLI (claude / code). |
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.
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_ALLOW_WRITES |
false |
true enables the data-write tools. |
VAYU_MCP_DISABLED_TOOLS |
(empty) | Comma-separated tool names to disable. |
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 (backlog M1).- 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