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/mcpCursor / 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 },
})agents.mcp: true. Not implied by anything else. Having an inspect token is not consent to let an agent execute procedures.VOLTRO_INSPECT_TOKEN— the transport is fail-closed;voltro devmints one per project,voltro servemints nothing.VOLTRO_INSPECT_WRITE_TOKEN+ thex-voltro-inspect-writeheader. 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.VOLTRO_AGENT_TOKEN— the app credential the call executes AS, sent on its ownx-voltro-agent-authorizationheader. 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. WithapiKeys: trueyour app already mints a scoped credential for this — scope it to what the agent may do, not to what you may do.agents.tools— the sameAppToolPolicythe in-processappToolsloop takes, so one policy covers both.denybeatsallow;includeWrites: trueis 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_TOKENnarrowly. - Call rate.
maxPerRunis reported for a client to honour; honouring it is the client's. Useplugin-ratelimiton 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'sAcceptallows it. AninitializePOST mints a session and returns it in theMcp-Session-Idheader; 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.