MCP server (voltro-mcp)

Wire a running Voltro api into Claude Code / Cursor as an MCP server — read the app's procedures, tables, workflows and JSON Schemas, EXECUTE the procedures you expose as agent tools under your app's own permissions, and run the framework's invariant checks. Over stdio or Streamable HTTP.

@voltro/mcp ships two standalone bins — voltro-mcp (stdio) and voltro-mcp-http (Streamable HTTP) — that serve a running api's capability manifest to a coding agent over the Model Context Protocol. The agent can then discover what the backend exposes — every rpc procedure with its input/output JSON Schema, the user tables, the workflows, the schema-driven-UI widget kinds — before writing UI or agent code.

Discovery is read-only metadata and is what you get with nothing configured beyond a URL: the bins talk to the same GET /_voltro/inspect/manifest endpoint the inspect surface exposes, and honour its token gate. Two further surfaces are off until you turn them on — executing your app's agent tools, and the invariant checks. Both are covered below. The server advertises three MCP capabilities — tools, resources, and prompts.

Setup

Four environment variables. The first two cover read-only discovery; the last two are what an executing tool call needs.

Var Default Notes
VOLTRO_INSPECT_URL http://localhost:4000 Base URL of the running api.
VOLTRO_INSPECT_TOKEN (unset) Sent as Authorization: Bearer <token>. The inspect surface is fail-closed, so without it every call is a 401.
VOLTRO_INSPECT_WRITE_TOKEN (unset) Required to EXECUTE an app tool — a tool call is a non-GET inspect request, and those need a second credential. Read-only discovery does not use it.
VOLTRO_AGENT_TOKEN (unset) The app credential a tool call executes as. Never the inspect token — see below.

Claude Code

claude mcp add voltro -- npx -y @voltro/mcp
# an api on a non-default port:
claude mcp add voltro --env VOLTRO_INSPECT_URL=http://localhost:4001 -- npx -y @voltro/mcp

Cursor / generic MCP config

{
  "mcpServers": {
    "voltro": {
      "command": "npx",
      "args": ["-y", "@voltro/mcp"],
      "env": { "VOLTRO_INSPECT_URL": "http://localhost:4000" }
    }
  }
}

The tools

Tool Returns
voltro_list_procedures Every rpc procedure with its kind; [public-rest] / [agent-tool] markers for projected descriptors.
voltro_get_procedure One procedure's kind, input/output JSON Schema, source file(s), table targets, and projections.
voltro_search_procedures Procedures whose tag contains a substring.
voltro_list_tables The app's USER tables (column count, reactivity).
voltro_get_table One table's full column list (types, nullability, FK targets, enums).
voltro_list_workflows The registered durable workflows.
voltro_list_widgets The schema-driven-UI widget kinds.
voltro_check_invariants The framework's invariant checks against the running app — see below.

Plus one app_<procedure> tool per procedure your app exposes as an agent tool AND its policy admits — see the next section. Those are the only tools that execute anything.

The resources

The server also exposes the manifest as MCP resources — stable, addressable voltro:// URIs an agent reads. resources/list enumerates them (fresh from the manifest each call); resources/read returns the metadata as JSON.

URI Contents
voltro://manifest The whole capability manifest as JSON.
voltro://procedure/<tag> One procedure's kind, input/output JSON Schema, source, and table targets.
voltro://table/<name> One table's full column list.

The prompts

Reusable MCP prompt templates that render against the live manifest, so the returned messages carry the app's real schema rather than a generic stub. prompts/list advertises them; prompts/get renders one.

Prompt Arguments Renders
scaffold_procedure kind, purpose A brief to draft a new query/mutation/action, listing the app's real tables + sibling procedures of that kind.
explain_table table The table's schema + the procedures that read/write it.
wire_ui_for_procedure tag A brief to call one procedure and render its result, embedding its real input/output schema.

Executing your app's procedures

A procedure annotated exposeAsTool can be called by the agent — the tool body is the real rpc handler, run under a Subject your app's own auth chain resolved. So the agent's ceiling is that subject's permissions, by construction: there is no second authorization path, because there is no second path. A guard that refuses the subject refuses the agent.

It is off until you say otherwise, at five independent gates:

// app.config.ts
export default {
  agents: {
    tools: { allow: ['todos.*'], deny: ['*.purge'], includeWrites: true },
    mcp: true,
  },
}
// mutations/todos.create.mutation.ts
export const descriptor = defineMutation({
  name: 'todos.create',
  input: Schema.Struct({ title: Schema.String }),
  guards: [{ scope: 'todos:write' }],
  exposeAsTool: { description: 'Create a todo for the signed-in user', confirm: false },
})
  1. agents.mcp: true. Not implied by anything else. Having an inspect token is not consent to let an agent execute procedures.
  2. VOLTRO_INSPECT_TOKEN — the transport is fail-closed; voltro dev mints one per project, voltro serve mints nothing.
  3. VOLTRO_INSPECT_WRITE_TOKEN + the x-voltro-inspect-write header. A tool call is a POST, and every non-GET inspect request already needed a second credential. An existing deployment with only the read token therefore executes nothing.
  4. VOLTRO_AGENT_TOKEN — the app credential the call executes AS, sent on its own x-voltro-agent-authorization header. Required. The inspect bearer is an operator credential; letting it double as an app identity would be exactly the second authorization path, and running as the anonymous subject instead would execute under a principal nobody chose. With apiKeys: true your app already mints a scoped credential for this — scope it to what the agent may do, not to what you may do.
  5. agents.tools — the same AppToolPolicy the in-process appTools loop takes, so one policy covers both. deny beats allow; includeWrites: true is required before any mutation or action is callable at all.

Then the app's own guards run. Nothing above replaces them.

confirm tools are not mounted here

confirm means a human approves the concrete call before it executes. There is no human in the MCP server's process, and there is no way to produce one: a confirmation carried in the tool's arguments is written by the model, and an MCP client's approval prompt is a property of that client — several harnesses auto-approve. So a confirm tool is dropped, with that reason, rather than mounted in the hope that the far side asks.

Writes confirm by default. An app that wants one callable unattended says so per descriptor (exposeAsTool: { confirm: false }) or app-wide (agents.tools.requireConfirmForWrites: false) — both are edits a reviewer sees in the diff.

Naming, and what an agent sees

todos.create mounts as app_todos_create (MCP tool names are [A-Za-z0-9_-]). The tag is resolved back through the listing the server rendered, never by un-mangling the name the model produced, so no amount of argument shaping selects a different procedure. Writes are marked [WRITE] in the description — the model has no other signal that one of two tools destroys data.

Everything that did NOT mount is reported with a reason (GET /_voltro/inspect/agent/tools returns dropped[]), because a tool silently missing from an agent's set is a support ticket that opens with "the agent says it can't do that".

In the audit trail

An agent call runs the same plugin interceptor chain as a socket call, so plugin-audit records it as usual. It additionally stamps via: 'agent' on the write attribution, with the subject id of the person the agent acted as — an agent never escalates identity, which is precisely why an unmarked agent write would be indistinguishable from a human one. A change-event tap reads it as event.via.

What this does NOT defend against

Stated rather than implied, because a bound you assume is worse than one you do not have:

  • Prompt injection that steers the model into misusing a tool it IS permitted to run. The allowlist bounds WHICH tools exist; it cannot bound intent. Tool results are your app's data, and app data can contain instructions.
  • A client holding all three credentials. It can call any admitted tool with any arguments. The bound is the subject's permissions — which is the design, and the reason to scope VOLTRO_AGENT_TOKEN narrowly.
  • Call rate. maxPerRun is reported for a client to honour; honouring it is the client's. Use plugin-ratelimit on the procedure for a bound that holds regardless of who is calling.

Verifying your own work

voltro_check_invariants runs the framework's own invariant checks against the running app and returns a machine-readable verdict — the loop that turns "I generated some code" into "I checked it". GET /_voltro/inspect/checks is the same thing over HTTP.

Check Answers
browser-safety Does the generated rpcGroup transitively value-import a server-only module? The finding carries the full import chain — a bare specifier says a rule broke, the chain says which shared lib/ file broke it.
procedure-access Does every wire-exposed procedure declare guards: or openAccess:? Runs the same verdict the boot gate runs, including security.defaultDeny.
schema-convergence Has the live schema drifted, and are operations pending? Read from the same snapshot voltro db plan --against reads.
server-only-exposure Does a wire-reachable query declare a .serverOnly() column in its output?

Each answers pass, fail, or unavailable — and unavailable is never a pass. Two of these read the source tree, and a deployed voltro serve has no generated rpcGroup to walk (frequently no src/ at all after a pnpm deploy), so it reports them unavailable with the reason rather than omitting them. Three green checks that you cannot distinguish from "nobody looked" would be worse than no answer.

{
  "checks": [
    { "id": "browser-safety", "status": "unavailable",
      "reason": "this process has no generated rpcGroup to walk — the check reads the SOURCE import graph…" },
    { "id": "procedure-access", "status": "fail",
      "summary": "1 wire-exposed procedure(s) declare no access decision",
      "findings": [{ "tag": "todos.secret", "kind": "query", "file": "queries/todos.secret.query.ts" }],
      "fix": "give each one either `guards: [{ scope: '…' }]` or `openAccess: '<why it is public>'`…" }
  ],
  "summary": { "pass": 2, "fail": 1, "unavailable": 1 },
  "mode": "serve"
}

voltro doctor's rule set is deliberately NOT here: it is a source-tree scan with its own allowlist file and exit-code contract, it would answer unavailable on the one deployment shape this surface exists to reach, and re-hosting it behind HTTP would be a second implementation of a large thing. Run the command, on the machine that has the source.

Streamable HTTP transport

For clients that speak MCP over HTTP, voltro-mcp-http serves the same surface over the current Streamable HTTP transport (the single-endpoint POST/GET model that replaced the old HTTP+SSE dual-endpoint). One endpoint handles:

  • POST a JSON-RPC message → a JSON response, or an SSE stream (text/event-stream) carrying the response(s) when the client's Accept allows it. An initialize POST mints a session and returns it in the Mcp-Session-Id header; every later POST must echo that header.
  • GET (with Accept: text/event-stream) → opens the server→client SSE channel.
  • DELETE → ends the session.
VOLTRO_MCP_HTTP_PORT=4100 VOLTRO_INSPECT_URL=http://localhost:4000 npx -y @voltro/mcp voltro-mcp-http
Var Default Notes
VOLTRO_MCP_HTTP_PORT 4100 Listen port.
VOLTRO_MCP_HTTP_PATH /mcp The single MCP endpoint path.

Freshness + failure behavior

The manifest is read through a TTL-cached source (~10 seconds): a procedure you add during a voltro dev session shows up on the next tool call — no MCP-server restart. When the api is unreachable or the token is wrong, the tool output says so ((no procedures — manifest unavailable: …)) and the bin logs the reason to stderr at boot, instead of silently presenting an empty app.

Protocol scope

MCP over JSON-RPC 2.0. initialize negotiates the protocol revision (2025-06-18, 2025-03-26, 2024-11-05) and advertises the tools, resources, and prompts capabilities; methods are ping, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. The stdio bin frames this as newline-delimited JSON-RPC; the HTTP bin serves it over Streamable HTTP. Both transports route to the same protocol core, all exported from @voltro/mcp: the pure handleMcpRequest (callTool, listResources/readResource, listPrompts/getPrompt) plus handleMcpRequestAsync, which handles the two methods that need a round trip to the app — tools/list folds in the admitted agent tools, and tools/call executes one or runs the invariant checks. With no live connection configured, handleMcpRequestAsync behaves exactly like the pure one.