Connect an MCP host to your VTA
By the end of this guide, your MCP host lists the VTA’s tools, and a vta_status call returns the bridge’s own identity and policy.
vta-mcp is a Model Context Protocol server that puts a VTA’s operations within reach of an MCP host. It is a bridge, not an authority: VTA operations run as authenticated requests against your VTA, and the VTA’s role, access control list (ACL), and context scope decide what actually happens. Some tools run inside the bridge without a VTA request: vta_status and vta_list_operations report the bridge’s own state and catalogue, issue_vp signs with a holder key configured on the bridge, and resolve_did uses the bridge’s own DID resolver. For how that authorisation is bounded, see Roles and access.
Connecting an agent framework to a signing or secrets backend otherwise means writing and maintaining bespoke client code for each project. Running the agent under your own operator credential avoids that work, at the cost of handing a tool-calling model the full reach of your admin session. The bridge adds its own local policy on top (--read-only, --allow, --deny, --confirm), because an MCP host approves a tool, not an individual call.
Use this guide when:
- You want an AI coding assistant or agent framework to sign, read the vault, or manage a VTA without you writing an integration.
- You want that access scoped to a dedicated identity, not your own operator session.
- You want the agent’s VTA operations to remain authenticated, policy-checked requests against the VTA, not a bypass around it.
Prerequisites
A running VTA connected to a DIDComm mediator, the relay service that carries DIDComm traffic between the bridge and the VTA. See DIDComm mediator deployment options if you haven’t set one up.
Install Rust 1.95 or later on your machine, to compile
pnmandvta-mcp.jq, if you register the bridge with Claude Code, to read values from the credential file in Step 5.pnminstalled and configured with a working VTA connection.cargo install pnm-cli@0.23.1 --locked --registry crates-ioSuper-admin access to the VTA (required for
pnm contexts create).An MCP-speaking host: Claude Code, Claude Desktop, or similar.
Step 1: Create a context for the bridge
Run the bridge as its own identity, scoped to its own context, not as yourself. This is the isolation boundary: whatever the bridge can reach is exactly what this context’s ACL grants it.
pnm contexts create \
--id mcp-bridge \
--name "MCP bridge"Step 2: Provision the bridge’s agent identity
The three commands below deliver the new credential through sealed transfer, with the work split between two roles. One person on one machine can play both:
- Recipient: the machine where the credential will be used.
pnm bootstrap requestcreates a one-time key pair, keeps the private key in PNM’s local configuration directory, and writes only the public key, a nonce, and an optional label to the request file. The recipient later runspnm bootstrap openon the same machine, since only that private key can open the bundle. - Producer: an administrator whose PNM is connected to the VTA.
pnm auth-credential createcreates the new DID, adds it to the context’s access control list (ACL), seals its credential to the public key in the request file, and prints a SHA-256 digest of the sealed bundle.
The digest proves that the bundle the recipient opens is the one the producer sealed, because pnm bootstrap open --expect-digest refuses any other bundle. When the roles run on separate machines, move the request file to the producer over a channel you trust, since the producer seals the credential to whichever public key that file carries. The bundle can travel back over any channel, provided the digest reaches the recipient separately over a channel you trust, for example read out over a call.
Here the recipient is the machine that will run the MCP host and the bridge.
pnm bootstrap request --out request.json
pnm auth-credential create \
--role application \
--contexts mcp-bridge \
--label "mcp-bridge-agent" \
--recipient request.json > bundle.txtCopy the SHA-256 digest printed to stderr, then open the bundle:
pnm bootstrap open \
--bundle bundle.txt \
--expect-digest <digest-from-previous-command> \
--out credential.jsoncredential.json now holds the three values vta-mcp needs: did, privateKeyMultibase, and vtaDid. The application role covers signing, vault release, and memory in its contexts, without context or ACL management. Delete bundle.txt and request.json once you’ve read credential.json, they’re single-use.
Step 3: Get your mediator DID
pnm services listCopy the DID from the Mediator: line under DIDComm:.
Step 4: Install vta-mcp
vta-mcp is distributed as source from the same repository as the VTA and pnm, while pnm itself is published on crates.io. Install it straight from that repository, pinned to the same release as the pnm version above so the two match:
cargo install --git https://github.com/OpenVTC/verifiable-trust-infrastructure \
--tag pnm-cli-v0.23.1 --locked vta-mcpCargo fetches and compiles the bridge for you, which takes a few minutes, then installs vta-mcp next to pnm in ~/.cargo/bin. Confirm it is on your PATH:
command -v vta-mcpStep 5: Register vta-mcp with your MCP host
Register the bridge under the name vta, passing the values from Steps 2 and 3:
- Claude Code: run
claude mcp addfrom the directory holdingcredential.json. It reads each value from the file withjq, so the private key stays out of your shell history.--scope usermakes the bridge available in every project. Leave out--scopeto register it for the current project only. - Claude Desktop: add the JSON block to
claude_desktop_config.json, found in~/Library/Application Support/Claude/on macOS or%APPDATA%\Claude\on Windows, then restart Claude Desktop. - Other MCP hosts: most take the same JSON block in their own MCP server config.
claude mcp add --scope user \
--env VTA_MCP_AGENT_KEY="$(jq -r .privateKeyMultibase credential.json)" \
vta -- "$(command -v vta-mcp)" \
--agent-did "$(jq -r .did credential.json)" \
--vta-did "$(jq -r .vtaDid credential.json)" \
--mediator-did <mediator-did-from-step-3> \
--confirm sensitive{
"mcpServers": {
"vta": {
"command": "/path/to/.cargo/bin/vta-mcp",
"args": [
"--agent-did", "<did-from-credential.json>",
"--vta-did", "<vtaDid-from-credential.json>",
"--mediator-did", "<mediator-did-from-step-3>",
"--confirm", "sensitive"
],
"env": { "VTA_MCP_AGENT_KEY": "<privateKeyMultibase-from-credential.json>" }
}
}
}Use the full path to the binary in command, which command -v vta-mcp prints, since a desktop host may start without your shell’s PATH.
If you keep the server in a project’s .mcp.json file instead, use the same JSON block with the key replaced by "VTA_MCP_AGENT_KEY": "${VTA_MCP_AGENT_KEY}". Claude Code expands the variable from your environment when it starts the server, so the key stays out of the file. Claude Code starts a server from .mcp.json only after you approve it, so approve vta when Claude Code prompts at the start of a session, or later from /mcp.
--confirm sensitive asks a human, through the host’s own approval prompt, before any operation that signs, releases a secret, or moves authority. That is tighter than the bridge’s destructive-only default.
Keep VTA_MCP_AGENT_KEY in env, not args. Any process on the machine can read a command-line argument, while an environment variable is readable only by processes running as the same user, and by root.
VTA_MCP_AGENT_KEY is the bridge’s private key. Never commit the MCP host config with the key inlined. Most hosts can reference a value from your secrets manager or a local file instead, which keeps the key out of what you check in. claude mcp add stores the key in ~/.claude.json, so keep that file readable only by you. While the registration command runs, the key is briefly visible as an argument of the claude process, so register the bridge on a machine you alone use.
Once the bridge is registered, store credential.json in your secrets manager or delete it.
Confirm the key is not echoed in the host’s MCP server log, which captures the server’s stderr. Rotate the bridge’s credential through Rotate an application credential if the config is ever shared or exposed.
What the bridge exposes
The bridge registers twelve MCP tools. Two of them reach every VTA operation, and the rest are shortcuts for the most common ones:
| Tool | What it does |
|---|---|
vta_call | Runs any VTA operation by name, with a JSON payload. Every VTA operation is reachable through this tool. |
vta_list_operations | Returns the catalogue of operations vta_call can reach. This is where the slugs for --allow and --deny come from. |
vta_status | Reports the bridge’s identity, its configured transports, and its current policy. |
vta_supported_tasks | Asks the VTA which tasks it serves. |
list_keys | Lists the signing keys available on the VTA. |
sign | Signs text with a VTA-held key, which stays on the VTA. |
vault_list | Lists vault entry metadata, without secret material. |
vault_get | Fetches one vault entry’s metadata by ID, without secret material. |
vault_release | Releases a vault secret sealed to the bridge and returns its value. |
resolve_did | Resolves a DID to its DID document. |
issue_vp | Issues a holder-bound Verifiable Presentation, as an OpenID for Verifiable Presentations (OID4VP) vp_token. |
device_heartbeat | Checks the bridge in with the VTA and returns anything waiting for it. |
Because vta_call is a single tool that names its operation per call, an MCP host approving “the vta_call tool” has approved the whole reachable surface at once. That is what the bridge’s own policy flags exist to narrow.
Narrow what the bridge can reach
| Flag | Effect |
|---|---|
--read-only | Refuses every operation that is not read-only, whatever the ACL permits. |
--deny <glob> | Refuses matching operation slugs. Checked first, and wins over --allow. |
--allow <glob> | Once set, anything not matching is denied. |
--confirm <level> | Asks a human through the host’s approval prompt before an operation runs. Levels are never, destructive (the default), sensitive, which also covers signing, secret release, and ACL changes, and always. |
Both filters match operation slugs as globs. Run vta_list_operations first to see the slugs this bridge actually reaches, rather than guessing at names.
These filters are local to the bridge and sit on top of the VTA’s own authorisation. They can only narrow what the bridge’s identity could already do; they never widen it. See the vta-mcp README for the full flag reference and every supported authentication mode.
Confirm
Test 1: the host lists the vta server as connected
In Claude Code, run:
claude mcp listThe vta entry should show Connected. Inside a Claude Code session, /mcp shows the same status along with the bridge’s tools. In Claude Desktop or another host, restart the host and check its MCP server list for vta.
Test 2: a vta_status call reports the bridge as connected
Ask the host to call vta_status. Expect a JSON response with "connected": true, along with the bridge’s identity, its transports, and its current policy. "connected": false means the bridge is running in degraded mode, and its connectError field says why.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
claude mcp list shows vta as Pending approval | The server is defined in a project’s .mcp.json, and Claude Code starts those only once approved | Start claude in that project and approve vta at the prompt, or approve it from /mcp. |
The host lists no tools for vta | The server exited before speaking MCP, usually a bad --confirm value or an unopenable path | Read the host’s own MCP server log for the exact startup error. |
Every tool except vta_status returns this vta-mcp bridge is not connected to its VTA: … | vta-mcp couldn’t connect with the flags given, so it’s serving MCP in degraded mode rather than exiting silently | The error names the missing value. Re-check --agent-did, --vta-did, --mediator-did, and VTA_MCP_AGENT_KEY. |
A tool call fails with '...' is not in this bridge's --allow list | --allow is set and the operation’s slug isn’t in it | Add the slug, or drop --allow if you don’t need it. |
vault_release fails with unsupported transport: opening a sealed vault secret requires the DIDComm transport | The released secret is encrypted with DIDComm authcrypt, and opening it needs a DIDComm connection | Confirm you’re using the did:key agent mode from this guide, not a REST/token mode. |
Tools return not connected to its VTA: connecting to VTA (did:key-didcomm): … even though the credential looks right | --mediator-did is wrong or points at a different VTA | Re-run pnm services list against the same VTA and copy the DID from the Mediator: line under DIDComm: again. |
Next steps
Glad to hear it! Please tell us how we can improve more.
Sorry to hear that. Please tell us how we can improve.
Thank you for sharing your feedback so we can improve your experience.