Docs / Local Vaults
Local vaults
mcpgate shows the model a token in place of each personal value. A local vault on the user’s computer gets the real value for a token directly from mcpgate, not through the model, and puts it into a local file or command.
The problem
A ticket system, a CRM or a mailbox answers with personal data. mcpgate replaces each value with a token such as ⟦EMAIL_1:ma•••@example.com⟧ before the model reads the answer. When the model puts the token into a tool call, mcpgate puts the real value back before the call reaches the service. That covers every call that goes through mcpgate.
A local file or a local command does not go through mcpgate. If the model writes a letter to a customer into a file, the file holds the token, not the address.
How a local vault closes the gap
A local vault is a hook in the AI client. It runs before a Write, Edit or Bash call. It sends each token in the call, with its key K, to mcpgate’s resolve endpoint and puts the real values into the call. The model and the transcript keep the token.
Connect maisecrets
maisecrets is a free plugin for Claude Code and Codex. Its vault speaks the protocol on this page, in Claude Code and in Codex. The first run from end to end passed on 17 October 2026: maisecrets put the values of five tokens from a help-desk ticket into a local file, and the model saw only the tokens. Four steps:
- Turn on the switch (operator). In the mcpgate admin, open Data sanitization, find the card “Local vaults”, turn on “Let local vaults read values” and save. The switch is off by default. When it is on, it applies to every user of the instance.
- Each user sets up maisecrets 0.6.36 or later: install it, add mcpgate as an MCP server of user scope with an
httpsURL, use mcpgate once, reconnect, and activate the link. The steps, the states and what to do in each: Vault references.
maisecrets resolves a token only in a session that got the token from that mcpgate server, and only for 24 hours after the server first showed it. A token from another source, such as a file or a web page, stays a token. A token in the argument of an MCP call also stays a token: it goes back to the server that made it. To type a value on a test device, see Appium, Selenium and LambdaTest.
Limits of this first version: on Windows, a Bash command gets no server value. In Codex, vault references need Python 3.11 or later and are off on Windows. A value under 8 characters, or a value that reads as a word (a first name), can show again in later tool output, for example when the AI runs cat on the file it wrote. maisecrets does not look for such a value, because a search for it would change every text that holds it.
Build your own vault
The protocol is open. A vault of your own needs these parts. The reference today is section 4 of docs/PROTOCOL.md in the maisecrets repository. A separate, vendor-neutral specification, with conformance cases and the reason for each decision, is in preparation and not public yet. Client-Vault and client-vault are not registered at IANA. This is a proposed protocol, not a standard.
A wider question, how any MCP server could give the model a reference instead of a sensitive value, is explored in Sensitive References for MCP, an independent pre-SEP design.
1. The key and the header
Make a random 256-bit secret K per server origin and per client profile. Keep K on the user’s computer. It goes only to the resolve endpoint, never on the MCP connection. Add a static header to every request to the server:
Client-Vault: v=1, kid=:<standard base64 of SHA-256(K), with padding>:
The header is an RFC 9651 dictionary. It carries a hash, never K.
2. Discovery
Read the protected resource metadata of the MCP URL (RFC 9728):
GET https://mcp.example.com/.well-known/oauth-protected-resource/mcp
While the operator switch is on, the answer has the field client_vault_endpoint. Use it only if it is https and has the same origin as the MCP URL. If the field is missing from a 200 answer, the server does not support local vaults.
3. Resolve
POST <client_vault_endpoint>
Authorization: Bearer <K, base64url without padding: 43 characters>
Content-Type: application/json
{"now": "2026-10-16T09:00:00Z",
"refs": [{"key": "EMAIL_1", "seen_at": "2026-10-16T08:58:12Z"}]}
keyis the token’s key without its display part.nowis the client clock.seen_atis the time the vault first saw the key. Do not move it to a later sighting: if the server mints the key again for another person, a later time would get that person’s value.- At most 100 keys and 64 KB per request.
- The endpoint refuses an OAuth token. Send
Konly.
4. The answer
The server answers with all values or with one error for the whole request. It never resolves a part.
| Status | Code | Meaning |
|---|---|---|
200 | — | {"values": {"EMAIL_1": "…"}}: one string for each requested key. "refs": [] is a status check and answers {"values": {}}. |
400 | bad_request | The body is malformed, or a key is in the client range (a key the vault made itself). |
401 | unknown_key | No active binding for this K. This includes a revoked or a blocked binding. |
403 | pending | The binding waits. The body has activates_at. |
403 | clock_skew | The client clock is more than 5 minutes off. The body has server_time. |
404 | unknown_ref | A key is not in the user’s current mapping. The body names the key. |
410 | expired | The server minted the key again after the vault first saw it. The key can now name another person. |
413 | too_large / too_many_refs | The body is above 64 KB, or it has more than 100 keys. |
429 | rate_limited | More than 100 values for this user today. Retry-After gives the seconds to midnight UTC. |
503 | disabled | The operator switch is off. |
Treat every other answer as a refusal: a redirect, another status, a body that is not JSON, a value that is not a string, or a key that is missing from a 200 answer.
Make your own MCP server speak it
The same contract, from the server side. mcpgate implements it as follows:
- Read the header. Parse
Client-Vaultas an RFC 9651 dictionary. Acceptv=1and akidof 32 bytes. - Bind at the sign-in. On the authorization-code exchange with PKCE, bind the
kidto the user at once. This request needs the PKCE verifier of the sign-in that the user did, so a stolen access token cannot use this path. - Bind pending on an MCP request. A
kidthat first shows on an MCP request binds pending. mcpgate activates it after 24 hours, at the next browser sign-in, or when the user activates it on the connections page. The user gets a notice on a channel the agent does not control (Slack direct message, else email). - One kid, one user. If a
kidshows with a second user, block it for both. mcpgate allows at most 5 vaults per user. - Publish the endpoint. Add
client_vault_endpointto the RFC 9728 metadata while the feature is on. - Resolve. Compare SHA-256 of the presented
Kwith the storedkid. Store thekidonly, neverK. Answer with the codes in the table above. - Audit. Write one audit line per resolve: the user, the
kidand the keys. Never the value, neverK.
Security properties and limits
- The model never gets the value. The vault puts it into the local call. The model and the transcript keep the token.
- K never reaches another origin. The vault takes the endpoint only over HTTPS and only on the origin of the MCP URL.
- A stolen access token can bind only pending: 24 hours, with a notice to the user, who can revoke it.
- A cap: 100 values per user per day.
- A key minted again for another person answers 410. An old token never gets the value of a new person.
- The limit: an AI agent that runs as the user can read
Kfrom the vault’s store, as it can read any stored value. The cap, the audit line, the notice and the revoke on the server bound that. A value that the vault writes into a file is plain text there.