MCP clients
Mount an external MCP server's tools onto a Voltro agent — through the same allow/deny policy your own `exposeAsTool` descriptors go through, with the untrusted server bounded.
@voltro/mcp points outward: it exposes your app to a coding agent as an MCP server. mcpToolset points inward — it connects to somebody else's MCP server (GitHub, Slack, Sentry, a Postgres bridge) and mounts its tools onto a Voltro agent.
The two halves speak the same protocol; only the direction differs.
Mounting a server
import { generateObjectWithTools, httpMcpTransport, mcpToolset } from '@voltro/ai'
const github = yield* mcpToolset(
{
namespace: 'github',
transport: httpMcpTransport({
server: 'github',
url: process.env.GITHUB_MCP_URL!,
headers: { authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN!}` },
}),
},
{
allow: ['github.search_*', 'github.get_*'],
readOnly: ['github.search_*', 'github.get_*'],
},
)
const { object } = yield* generateObjectWithTools({
prompt,
tools: github.tools,
schema: Result,
})github.tools is a Record<string, AnyTool> keyed by the namespaced tag (github.get_issue) — the same shape appTools produces, so one agent can mix its own tools and an external server's.
For a locally spawned server, use the stdio transport instead:
import { stdioMcpTransport } from '@voltro/ai'
const files = yield* mcpToolset(
{
namespace: 'files',
transport: stdioMcpTransport({
server: 'files',
command: 'npx',
args: ['-y', 'some-mcp-server'],
// The child does NOT inherit your process environment. Pass only what it needs.
env: { HOME: process.env.HOME! },
}),
},
{ allow: ['files.read_*'], readOnly: ['files.read_*'] },
)Credentials always come from your environment or config. The framework ships no default token for any server.
The policy is the ceiling
An exposeAsTool descriptor executes your handler under the caller's subject, so an agent's ceiling is that subject's permissions by construction. An external MCP server has no such property — it runs elsewhere, with whatever credentials you gave it. So for external tools the policy is the ceiling, and it is deliberately stricter:
App tools (appTools) |
External tools (mcpToolset) |
|
|---|---|---|
| Default exposure | Only descriptors annotated exposeAsTool |
None — allow is required |
| Allow / deny | passesPolicy, deny beats allow |
The same function, same globs |
| Read vs write | The descriptor's kind |
The app's readOnly list, not the server's hint |
| Writes | includeWrites: true |
includeWrites: true |
| Confirm | On for writes by default | On for writes by default |
Three consequences worth stating outright:
- Omitting
allowis refused, not defaulted. A descriptor got a per-tool decision when somebody wroteexposeAsToolon it. Nobody in your repository wrote anything about a server's 94 tools, so naming what you allow is that decision. - A server's
readOnlyHintdoes not classify a tool. The server is the untrusted party and can change the hint between twotools/listcalls, so believing it would be a way to talk pastincludeWrites: false. SettrustToolHints: trueif you want to delegate that judgement — explicitly, in one place a reviewer can find. - The gate runs again inside every tool body, so a tool spliced into the record after mount still cannot reach the server.
toolset.specs is a SynthesizedTool[] — the same inventory type app tools produce — so one confirm-UI covers both kinds. toolset.dropped lists everything the server advertised that did not mount, and why.
Bounding an untrusted server
A server's tool names, descriptions, schemas and results all reach your model's context. Every one of them is bounded, and every bound is an option with a default and an environment override:
| Bound | Default | Env |
|---|---|---|
maxTools |
64 | VOLTRO_MCP_MAX_TOOLS |
maxDescriptionChars |
1024 | VOLTRO_MCP_MAX_DESCRIPTION_CHARS |
maxSchemaBytes |
32 KiB | VOLTRO_MCP_MAX_SCHEMA_BYTES |
maxResultBytes |
256 KiB | VOLTRO_MCP_MAX_RESULT_BYTES |
maxResponseBytes |
4 MiB | VOLTRO_MCP_MAX_RESPONSE_BYTES |
requestTimeoutMs |
30 000 | VOLTRO_MCP_TIMEOUT_MS |
yield* mcpToolset(server, {
allow: ['github.get_*'],
bounds: { maxTools: 12, maxResultBytes: 32 * 1024 },
})Alongside the numbers:
- Tool names must be
[A-Za-z0-9_-]. Anything else is dropped rather than sanitized — a truncated name would not be the name you allowed. - Descriptions and schemas are stripped of invisible characters (zero-width spaces, bidi overrides, the Unicode tags block) before they reach the model. Those are how an injection hides from the human reviewing the same string.
- Every description carries a provenance prefix telling the model the text is third-party, not an instruction from your app.
- Non-text results are described, not inlined — a 6 MB base64 image does not buy a context window.
- The tool set is snapshotted at mount. Nothing re-reads
tools/liston its own; a server that renames or re-describes its tools between calls changes nothing until you callrefreshMcpToolset.
What this does not defend against
Stated plainly, because a bound you assume is worse than one you know you lack:
- Instructions inside a description or a result. They are bounded, sanitized and labelled, but a model may still choose to obey them. What actually contains the damage is the allowlist above: an injected "now call
admin_delete_all" reaches a tool that was never mounted. - A server that lies about a tool's effect, or does something destructive inside a tool you allowed. The ceiling there is the credentials you gave the server — scope them.
- Argument exfiltration. The allowlist bounds which tools run, not what the model puts in their arguments. A mounted external tool is a channel out of your process; do not mount one alongside tools that read secrets and expect the two not to meet.
- The transport target.
url/commandare treated as app configuration. There is no SSRF guard or binary allowlist, because a legitimate deployment mounts an MCP server on a private address. If either can be influenced by user or model input in your app, gate it there.