Connect an AI agent through MCP
© Pi Soft, 2018-2026 · Tricryption Engine 8.1
A Model Context Protocol server, in Python, built on this
starter's client.py. An MCP host is an agent runtime that connects to the server and lets a
model call its tools. Through this server it gets the tools its task grant names, and no
others, over data encrypted under keys the key server holds, reached through a Tricryption
REST Gateway (contract 1.7.1). The server runs one of two ways:
- over stdio, started by the host as a child process, for one local user; or
- over Streamable HTTP, on the loopback interface with TLS, for many users at once, each acting as themselves: the host presents its signed-in user's access token and the key server decides what that user's agent may open (see Serving many users over Streamable HTTP).
There are three document tools:
| tool | arguments | what it does |
|---|---|---|
list_approved_documents | category (optional) | the document ids this task is approved to read, their non-sensitive labels, and the destinations it may save to |
read_protected_document | documentId | decrypts one approved document for this task and returns its text -- bounded, UTF-8, marked as untrusted document content, with its provenance (id, version, sha256) |
save_protected_result | destinationId, text, idempotencyKey | encrypts a result under the destination's existing key and returns a receipt; the same idempotencyKey again returns the first receipt and writes nothing |
And two file tools, offered only when the grant names a file root -- one folder the launcher chose (see Files):
| tool | arguments | what it does |
|---|---|---|
encrypt_file | path | encrypts one file in the task's folder under a new key the key server creates for it, and writes <name>.pisoft beside it; the original stays. Returns the envelope's path and the original's size and sha256 |
decrypt_file | path | decrypts one .pisoft that encrypt_file made in that folder, and writes the plaintext beside it under a new name. Returns that path, its size and sha256 -- never the content |
And two section tools for a compound document -- one whose sections are each under their own
key (see Compound documents): read_protected_fragment and
save_protected_fragment.
And two hand-off tools, offered only when the grant names steps you defined -- one user's agent
handing sections that user holds to another user's agent, for one step (see
Handing sections to another user): hand_off and
close_step.
tools/list answers exactly the grant's allowedTools, and a call to any other tool is refused.
What the agent can and cannot choose
The agent chooses which approved document to read, what to write, -- with a file root -- which file in that folder to encrypt or decrypt, and -- with steps -- which of the grant's steps to hand on or close. Nothing else.
It never chooses an identity, a delegator, a Gateway, a URL, a key, a t-Tag or a credential, and never a file outside the task's folder. No tool has an argument for any of them, and every input schema is closed. An argument a tool does not declare is refused, never silently dropped.
Three layers decide what a call may do, and each one answers for itself:
| layer | what decides | what a refusal looks like |
|---|---|---|
| adapter (this server) | the task grant: which tools, which document ids, which destinations, until when. A trusted launcher writes it before the agent starts; no tool argument can change it. | DOCUMENT_NOT_IN_GRANT, DESTINATION_NOT_IN_GRANT, GRANT_EXPIRED, UNEXPECTED_ARGUMENT, DESTINATION_HAS_NO_KEY, RESULT_TOO_LARGE, ... -- nothing was sent anywhere |
| gateway | a scoped session minted for the grant's user. It covers exactly the grant's documents and destinations, is sender-constrained to a fresh ES256 (P-256) key this server generates for the task, and lives at most five minutes. | OBJECT_NOT_IN_CREDENTIAL_SCOPE (403) -- the key server was not asked; PROOF_* (401) |
| keyserver | every decrypt and encrypt, for the user the session acts as: its roles, then the key's access control list -- every time | ACL_DENIED (403), ACCESS_DENIED (403), ... with a sentence saying what to do: do not retry |
A refusal is an MCP tool result with isError: true. Its structured content is
{"refused": true, "layer": ..., "code": ..., "status": ..., "detail": ...}, so the host, the
model and you can all tell which layer said no.
The server itself signs in as your registered service, using the service's certificate and
its sender key, named by path in config.json. That is this prototype's boundary: anything
that controls the server process can use the service's authority. What the model can reach
is narrower: the grant, then the Gateway's scope, then the key server's decision.
Install the MCP SDK
The server needs the MCP Python SDK. Install it in a virtual environment, from the hash lock
beside this file and nothing else. requirements.lock pins the SDK and its whole closure, one
version each, with every published sha256. --require-hashes checks every wheel against the
lock, and --no-deps installs nothing the lock does not name. Run the commands on this page
from the starter's own directory (examples/python-agent-starter), with VENV set to where the
environment should live:
python3 -m venv "$VENV"
"$VENV/bin/python" -m pip install --require-hashes --no-deps -r mcp/requirements.lock
On Windows the interpreter is "$VENV\Scripts\python.exe". The starter itself (client.py)
stays standard library only; only the server under mcp/ imports the SDK.
The operator's steps
1. Register the service with the scoped operation
The registration is the starter's operator bootstrap with
one more flag, --allow-scoped. That flag lets the service mint a scoped, sender-constrained
session. Its config.json also carries the XAuth prefix the server checks every scoped session
against. Against an evaluation Gateway on the loopback interface:
python3 bootstrap/bootstrap.py --endpoint "$GATEWAY" --allow-http-loopback --openssl "$OPENSSL" \
--admin-user admin --admin-password-file "$ADMIN_PASSWORD_FILE" \
--service-cert "$SERVICE_CERT" --service-login-key "$SERVICE_KEY" --sender-key "$SENDER_KEY" \
--delegator-cert "$DELEGATOR_CERT" --registry-file "$REGISTRY_FILE" \
--allow-scoped --config-out config.json
Against a production Gateway, use its https:// origin and --trust-file, as the operator page
shows. The bootstrap must end BOOTSTRAP: OK.
2. Seed the documents, and the manifest
The server never takes a path, a key or an IV from the model. It resolves ids through an
operator manifest (manifest.json). For each document the manifest records:
- where its ciphertext is;
- its t-Tag and its IV;
- the sha256 of its plaintext, its size, version and content type.
For each save destination it records the t-Tag of that destination's existing key. Every document and every saved version carries its own random 16-byte IV. Without one, the key server's encrypt is deterministic: the same text under the same key gives the same ciphertext.
seed.py makes a complete synthetic set to try the server with, as the registered service,
through the Gateway:
- five supplier proposals, one of them deliberately not approved to the agent's user;
- their READ grants;
- a save destination and its key;
- the manifest.
python3 mcp/seed.py --config config.json --out-dir task-data
It prints the agent's user (<tag>.agent). READ on a key is enough for a user to encrypt a new
result under it, so the seed grants READ on the destination's key and checks it.
3. Write the task grant
The trusted launcher writes the grant. That is you, or the application that starts the agent:
python3 mcp/launch_grant.py --manifest task-data/manifest.json --all-approved --ttl-seconds 900 \
--out task-data/grant.json
--all-approved takes every document the manifest marks approved, every destination, and the
seed's agent user. To choose instead, name them with --local-user, --documents a,b,c and
--destinations d. The grant is written mode 600, and the server refuses a grant that others
can write.
For a file task, name the one folder the agent may work in with --file-root:
python3 mcp/launch_grant.py --manifest task-data/manifest.json --local-user "$AGENT_USER" \
--file-root "$TASK_FOLDER" --ttl-seconds 900 --out task-data/grant.json
The grant records the folder resolved and absolute, as fileRoot, and its tools default to
encrypt_file,decrypt_file (name others with --tools). The launcher refuses a folder that does
not exist, a filesystem root, a home directory itself, and a source checkout (or a folder holding
one, or inside one). A scoped session needs at least one object, so with no --destinations the
launcher anchors the task's session on the manifest's first destination that has a key -- the
operator-seeded key, never one the model names.
Connect an MCP host
Over stdio, an MCP host starts the server as a child process and speaks JSON-RPC over the server's
standard input and output (for the HTTP transport, see
Serving many users over Streamable HTTP). host_config.py writes the host's configuration for this server, with every
path absolute:
python3 mcp/host_config.py --python "$VENV/bin/python" --config config.json \
--manifest task-data/manifest.json --grant task-data/grant.json \
--audit task-data/audit.jsonl --keep LD_LIBRARY_PATH --out mcp-config.json
It writes the standard stdio form, which any MCP host that reads an mcpServers file accepts:
{
"mcpServers": {
"protected-documents": {
"command": "/path/to/venv/bin/python",
"args": ["-B", "/path/to/mcp/clean_env.py", "--keep", "LD_LIBRARY_PATH", "--",
"/path/to/venv/bin/python", "-B", "/path/to/mcp/server.py",
"--config", "/path/to/config.json", "--manifest", "/path/to/task-data/manifest.json",
"--grant", "/path/to/task-data/grant.json", "--audit", "/path/to/task-data/audit.jsonl"]
}
}
}
Why the server starts through clean_env.py. A host hands a stdio server its own
environment, and an env block in the configuration only adds to it. Claude Code, measured,
passes everything, including the API key that pays for the model. clean_env.py starts the
server with only what a program needs: PATH, the home and temporary directories and, with
--keep, a variable you name. Give --keep LD_LIBRARY_PATH if the OpenSSL executable your
config.json names needs a library path. The server also refuses to start with a model
credential in its environment, naming the variable and never its value. A configuration that
forgets the launcher therefore fails closed instead of leaking the key.
--format codex-toml writes the same launch as one [mcp_servers.<name>] TOML table --
command, args, cwd (the grant's file root by default), startup_timeout_sec,
tool_timeout_sec, enabled_tools (the grant's allowedTools), required = true and
default_tools_approval_mode -- for a host that reads its servers from a TOML file; see
Connecting other MCP hosts.
The server refuses to start with any model host's credential in its environment: the names and
prefixes are in mcp/model_credentials.py, Codex's and OpenAI's included (CODEX_API_KEY,
CODEX_ACCESS_TOKEN, OPENAI_API_KEY, OPENAI_IDENTITY_TOKEN_FILE, CODEX_HOME, and anything
beginning CODEX_ or OPENAI_).
Allow the host only this server's tools, and no other server and no built-in tool.
The host the demonstration used: Claude Code
The demonstration ran Claude Code as the host, through mcp/demo/run-agent.sh. That script
starts claude -p with these settings:
--strict-mcp-configand this configuration alone;- no built-in tool (
--tools ""); --allowedToolsnaming the three document tools;--permission-mode dontAsk;- a spend cap (
--max-budget-usd, default 1) and a turn cap (--max-turns, default 12); - an isolated configuration directory.
It authenticates with an API key read from a file you name, which must be mode 600. The
key goes into Claude Code's environment for that one process. It is never on a command line,
and every other Claude credential variable is removed first. Every event the host emits is
saved to <run-dir>/events.jsonl.
mcp/demo/run-agent.sh --run-dir run-1 --mcp-config mcp-config.json --server-name protected-documents \
--key-file "$ANTHROPIC_KEY_FILE" \
--prompt "List the approved documents, then read the evaluation criteria and summarize them in two lines."
Then the server's audit has one line per tool call:
tail -n 3 task-data/audit.jsonl
Each line holds:
runIdandgrantId;actingAs;- the session's public key thumbprint
jktand a 12-hex sha256 prefix of the session handle; tool,documentIdandversion;outcome, and for a refusal the refusinglayer, itscodeandstatus;startedAt,latencyMs, and the Gateway requests the call made.
It holds no handle, proof, key or document text. Each line joins to the key server's own audit record of the same request by the acting principal, the object's hidden link and time. A refusal by the key server has a FAILED row there. A refusal by the adapter or the Gateway has none, because the key server was never asked.
Connecting other MCP hosts
The server is a plain stdio MCP server, so any host that starts a child process can run it. What differs from host to host is how the host is told about the server, what environment it hands the server, and what else it offers the model. For any host:
- Generate the configuration; never type it.
host_config.pywrites the standardmcpServersJSON (above), or, with--format codex-toml, the same launch as one[mcp_servers.<name>]TOML table. Either way every path is absolute and the command is theclean_env.pylaunch. - Keep the launcher. The server starts through
clean_env.pywhatever the host passes, and refuses to start with a model host's credential in its environment, naming the variable and never its value. Hosts differ: one passed its whole environment, another a fixed list of names, and a host may forward a variable its configuration names. - Offer the model this server's tools only. Turn off every other server, plugin and built-in tool the host offers, and check what the model is actually offered, not only what the configuration names.
- Know what the host's sandbox bounds. The server's tools refuse any path outside the task's folder; the host's own shell is not bound by that.
One other host has been tried, as an experiment rather than a supported host:
examples/python-agent-starter/mcp/demo/CODEX.md records what was measured, and it found that an agent host's
sandbox may let the model READ files outside its workspace even while it bounds what it writes.
Serving many users over Streamable HTTP
Over stdio the server acts for the ONE user its grant names. Started with --transport http, it
serves Streamable HTTP at https://127.0.0.1:<port>/mcp instead, and every request carries the
access token of the user the host signed in. The server is then an OAuth 2.1 resource
server, and each user's agent acts as that user:
- The token is checked here. The server validates each request's token itself, against your
identity provider's published keys (fetched over TLS, trusting only the CA file you name): the
signing algorithm, the issuer, the expiry, an audience that names this server, that it is an
access token and not an ID token, and its subject. A request without a valid token is answered
401withWWW-Authenticate: Bearer ... resource_metadata="...", and the server publishes its protected-resource metadata athttps://127.0.0.1:<port>/.well-known/oauth-protected-resource/mcp, naming your provider -- which is how a host finds out where to sign its user in. - The token goes no further. For the Gateway, the server exchanges it at your provider (OAuth 2.0 Token Exchange, as its own client) for a token issued to the Gateway's API audience, and presents only that one. Nothing a host sends is forwarded.
- One session per user. The Gateway maps the user's identity to a principal inside your service's prefix and mints a scoped session acting as that principal; the server keeps one per user -- its own holder key, session and audit identity. The key server's roles and access control lists then decide, every call, what that user's agent may open. Two users of one server never share a session.
- Identity is never an argument. The tools and their input schemas are exactly the stdio ones. The model cannot name a user, and the grant names none.
What the operator configures
At the identity provider:
- a confidential client for this server (its id is the audience a host's token must carry, and the client that performs the exchange), allowed to use token exchange to the Gateway's API audience; keep its secret in a file, mode 600;
- the Gateway's API audience -- with Keycloak, a bearer-only client and an optional client scope of the same name on this server's client (the exchange asks for the audience AND that scope);
- a client for each MCP host (public, PKCE, a loopback redirect) whose access tokens carry this
server's client id as their audience -- with Keycloak, an audience mapper in a default client
scope of the host's client. Register it in advance: Keycloak's default registration policies
refuse a host that tries to register itself anonymously (
Trusted Hosts,Allowed Client Scopes); - no
offline_accessfor the host's client, and none in the provider's metadata. Keycloak's token exchange refuses a token issued on an offline session (400 invalid_request, "Invalid token"), and an OAuth client that follows the MCP authorization rules addsoffline_accessto its request whenever the provider's metadata lists it. With Keycloak: remove the scope from the host's client, and turn off Include in OpenID Provider Metadata on theoffline_accessclient scope. A host that still asks for it is then refused at sign-in (invalid_scope), where its user sees it.
At the Gateway (its OpenID Connect configuration):
- a service binding for your registered service, naming the provider, the API audience, and this server's client as the allowed client;
- a mapping for each user, from their (issuer, subject) to a principal name inside your service's prefix. An identity with no enabled mapping is refused; none is created.
For the server: a TLS certificate and key for 127.0.0.1, a grant written with
--caller-identity (it names no --local-user; the server refuses a grant that names one), and
a configuration file. The certificate must be a server certificate issued by a CA, not a CA
itself and not self-signed: basicConstraints CA:FALSE, extended key usage serverAuth, a
subject alternative name IP:127.0.0.1. Hosts are given that CA to trust. Some hosts' TLS
stacks refuse a CA certificate served as the server's own certificate even when they trust it,
and fail before any sign-in starts.
python3 mcp/launch_grant.py --manifest task-data/manifest.json --caller-identity \
--documents proposal-aurora,evaluation-criteria --destinations evaluation-summary \
--ttl-seconds 3600 --out task-data/grant.json
{
"listen": {"host": "127.0.0.1", "port": 8443},
"tls": {"certificate": "mcp-tls.pem", "key": "mcp-tls.key"},
"provider": {"issuer": "https://idp.example/realms/main", "caFile": "idp-ca.pem",
"allowedAlgs": ["RS256", "ES256"], "jwksRefreshMinSeconds": 5},
"exchange": {"clientId": "mcp-adapter", "clientSecretFile": "mcp-adapter-client-secret",
"gatewayAudience": "gateway-api"},
"requiredScopes": ["openid"],
"maxCallers": 8
}
Paths are relative to the file. audience (optional) defaults to exchange.clientId;
resource defaults to https://127.0.0.1:<port>/mcp. Only 127.0.0.1 is served, and only over
TLS. requiredScopes is also the scopes_supported the metadata advertises, so a host's OAuth
client asks for exactly those. Start it, with the environment clean_env.py gives:
"$VENV/bin/python" mcp/clean_env.py --keep LD_LIBRARY_PATH -- "$VENV/bin/python" -B mcp/server.py \
--transport http --http-config mcp-http.json --config config.json \
--manifest task-data/manifest.json --grant task-data/grant.json --audit task-data/audit.jsonl
What a host needs
The URL, https://127.0.0.1:<port>/mcp, trust for the certificates on the way, and a way to sign
its user in at your provider and send that user's access token as Authorization: Bearer. A host
that follows the protected-resource metadata discovers the provider on its own.
The certificates a host trusts are the CA that issued this server's certificate, and your provider's CA when the provider's certificate is not from a public CA. Never give a host the server's own certificate as its trust anchor. Put both CAs in one file when the host takes one.
What a host's OAuth client does. Measured with the MCP Python SDK's own OAuth client
(mcp 2.3.0) against Keycloak 26.8.1, the host's client registered in advance:
- its first request to
/mcpis answered401, and it reads theresource_metadataURL the answer names; - it reads the server's protected-resource metadata, and then the provider's authorization-server
metadata (
/.well-known/oauth-authorization-server/<issuer path>); - it sends the user to the provider's sign-in page with PKCE (
S256), the scope the metadata advertises (openid), and the server's URL asresource. Keycloak ignoresresourceunless its resource-indicators feature is on, so the token's audience comes from the audience mapper; - it redeems the code with no client secret and repeats its request with the token, which this server accepts.
Claude Code. Measured with Claude Code 2.1.283, configuration only (no sign-in was run):
claude mcp add --transport http --client-id mcp-host --callback-port 18124 \
protected-documents https://127.0.0.1:<port>/mcp
--client-id is the host's client at your provider, and --callback-port is the fixed port of
its redirect. Register both http://localhost:18124/callback and http://127.0.0.1:18124/callback
for that client at the provider. Keycloak matches redirects exactly, so another port is refused. When the server's
or the provider's certificate comes from your own CA, start Claude Code with
NODE_EXTRA_CA_CERTS naming a file that holds both CAs. claude mcp list then reports the server as
! Needs authentication, and claude mcp get protected-documents shows OAuth: client_id configured, callback_port 18124. The user signs in from /mcp inside Claude Code: choose the
server and authenticate.
Signing in from Claude Code when no browser opens. Measured with Claude Code 2.1.283 against Keycloak: Claude Code's own attempt to open a browser did not give a usable sign-in page, and the sign-in link it shows did.
- Copy the sign-in link Claude Code shows.
- Open it in a browser on the same machine as Claude Code. When the user has signed in, the
provider sends the browser to
http://localhost:<callback port>/callback, and only the machine running Claude Code answers on that address. - Use a browser that trusts the provider's certificate. If the provider's certificate comes from your own CA, that browser must trust the CA, or the sign-in page does not load at all.
The token Claude Code obtained asked for the scope openid only, from the host's client, with
the callback on port 18124. That token lives as long as the provider's access tokens do (300
seconds in the run measured) and came with no refresh token, so after an idle spell
Claude Code asks the user to sign in again. The server's audit file shows each refused request,
and why (see Reading a refused request below).
Codex (configured, not yet run to a sign-in). Measured with Codex CLI 0.164.0, configuration
in its config.toml:
[mcp_servers.protected-documents]
url = "https://127.0.0.1:<port>/mcp"
scopes = ["openid"]
[mcp_servers.protected-documents.oauth]
client_id = "mcp-host"
callback_url = "http://127.0.0.1:18124/callback"
callback_port = 18124
With a server certificate that was itself a CA, Codex failed before any sign-in, at
OAuth metadata discovery failed for https://127.0.0.1:<port>/mcp: its TLS stack refuses a CA
certificate served as the server's own. A sign-in through Codex against a server certificate
issued by a separate CA, and how Codex is told to trust your CA, were not measured.
Other hosts were not measured.
What changes for a refusal
The layers are the stdio ones, plus the user's identity:
| refusal | layer | what it means |
|---|---|---|
401 (HTTP) | -- | no token, or one this server does not accept (wrong audience, expired, not an access token, unknown signer); nothing was sent anywhere, and the audit file says which (Reading a refused request) |
TOKEN_EXCHANGE_REFUSED | adapter | the provider would not exchange the user's token, and its own reason is in the detail (for example Keycloak's "Invalid token" for a token from an offline session); sign in again, or the operator allows the exchange |
IDENTITY_NOT_MAPPED, SUBJECT_TOKEN_REFUSED | gateway | the Gateway knows no user of this service for that identity, or did not accept the exchanged token; final |
IDP_UNAVAILABLE | gateway | the Gateway could not check the token just now (the provider rotated its key): the server waits a short Retry-After out once and presents it again; a longer one is returned with retryAfter |
One user's refusal never stops the server for anyone else.
Reading a refused request
Every request the server answers 401 leaves one line on its standard error and one JSON line in
its audit file, beside the lines for tool calls:
{"event": "auth-refused", "class": "expired", "reason": "TOKEN_EXPIRED", "status": 401,
"method": "POST", "path": "/mcp", "ts": "2026-10-24T12:00:00Z"}
The line never holds the token, anything from inside it (not its subject or key id), or any
header value. Lines for tool calls carry tool; a refusal line carries "event": "auth-refused"
instead. The class is one of:
| class | what was refused |
|---|---|
no-token | a request with no Authorization: Bearer token: a host that has not signed its user in yet (its first request always is one) |
expired | a token past its expiry: the user signs in again |
not-yet-valid | a token whose validity starts later (check the clocks) |
wrong-audience | a token not issued for this server: its audience does not name this server's client, or it has none |
wrong-issuer | a token from another issuer than the configured provider |
bad-signature | a token whose signature does not verify under the provider's key |
unknown-key | a token signed by a key the provider does not publish |
not-an-access-token | an ID token, or another token that is not an access token |
malformed | something that is not a token at all |
claim-missing | a token missing a claim this server requires |
invalid | any other token this server would not accept (for example a signing algorithm it does not allow) |
provider-unavailable | the server could not read the provider's keys just now |
reason is the server's own code for the same refusal.
How long things live
- Each user's scoped session lives at most five minutes, and no longer than the exchanged token; the server mints the next one, and exchanges again, as needed.
- At most
maxCallersusers hold a session at once. Past that, the user who called least recently is ended -- their session revoked, their holder key destroyed -- and served again, by a new session, when they return. - The server's own sign-in as the registered service is shared by every user. If the Gateway has
dropped it after an idle spell, the server signs in again once and carries on; the audit line of
that call says
serviceRelogin: true. - Saves and file encryptions are kept apart per user: two users choosing the same
idempotencyKey, or encrypting the same file content at the same path, each get their own result and key. The record of which keys this server created is shared, so one user can open a file another encrypted only if the key server's access control list lets them. - On
SIGTERMorSIGINTevery user's session is revoked and every holder key destroyed.
Saves
save_protected_result works in this order:
- It reserves its
idempotencyKeyin a small journal (<manifest>.journal.sqliteby default), before asking the Gateway to encrypt. - It writes the ciphertext to a new versioned file,
results/<destination>.v<n>.bin, never over an existing one. - It commits by renaming the updated manifest into place.
A retry with the same key returns the first receipt. The same key with different text is
refused IDEMPOTENCY_CONFLICT.
Files
encrypt_file and decrypt_file take one argument, path: absolute, or relative to the
grant's file root. Before any Gateway call, the server resolves the path -- symbolic links,
junctions and .. first, case-insensitively on Windows -- and accepts only a regular file
strictly inside the resolved folder, with one name. Everything else is refused by the
adapter, with nothing sent:
| code | what was refused |
|---|---|
OUTSIDE_ROOT | a path that resolves outside the folder (or to the folder itself): an absolute path elsewhere, .., a link or junction that leaves it. Judged before existence, so the refusal says nothing about what exists outside. |
UNC_OR_DEVICE_PATH, ALTERNATE_STREAM, PATH_FORM_REFUSED, PATH_EMPTY | \\server\share, \\?\, \\.\; a : anywhere but a drive letter (name:stream, refused on every platform); a control character; nothing |
NOT_FOUND, IS_A_DIRECTORY, NOT_A_REGULAR_FILE, HARD_LINKED, FILE_CHANGED | no file; a folder; a device or pipe; a file with a second name, which may be outside the folder; a file swapped between the check and the read |
ALREADY_AN_ENVELOPE, NOT_AN_ENVELOPE, EMPTY_FILE, FILE_TOO_LARGE | a .pisoft given to encrypt_file; anything else given to decrypt_file; an empty file; a file over 16,777,215 bytes |
ENVELOPE_EXISTS | <name>.pisoft is already there; it is never overwritten |
KEY_NOT_MINTED_HERE | an envelope naming a key this folder's encrypt_file did not create |
The key. encrypt_file sends the Gateway an encrypt without a t-Tag, so the key server
creates a new key for the user the task acts as, and answers minted: true. A journal row for
(folder, path, content sha256) is reserved first. Encrypting the same file with the same content
again returns the first result and creates no second key; if the envelope was deleted it is
written again from the journal. An encrypt whose answer is lost is not retried: the result
says a key may have been created and left unused.
The envelope is JSON, pisoft-file-envelope version 1:
{"format": "pisoft-file-envelope", "formatVersion": 1, "createdAt": "2026-10-22T12:00:00Z",
"ttag": "<base64>", "iv": "<base64>", "ciphertext": "<base64>",
"plaintext": {"name": "report.docx", "bytes": 48213, "sha256": "<hex>"}}
It carries no session handle, proof, credential or plaintext.
Decrypting. decrypt_file honors only a key the journal says encrypt_file created in the
same folder. The envelope is a file the model can edit, so a key named in it is never trusted on
its own. The server then widens the task's session to that key -- revoking the previous session
first, so the task keeps one -- and writes the plaintext as <name>.decrypted<ext> (then
-2, -3, ...) only if its sha256 matches the one recorded when the file was encrypted.
The size limit is measured, not chosen. The binding limit is the key server connection's
16 MiB per-field limit on the ciphertext coming back, not the Gateway's request-body limit:
16,777,215 bytes round-tripped and 16,777,216 did not. The Gateway's encrypt operation refuses
anything larger with 400 BAD_REQUEST before the key server is asked, and the server's limit
is that same number, so a file over it is refused here with no request sent.
Each file call's audit line adds path (relative to the folder), plaintextSha256, minted,
and keyLink: a 12-hex sha256 prefix of the new key's hidden link. That prefix joins the line
to the key server's own row for the request.
Encrypting a file protects it at rest. It does not protect it from the model that wrote it.
Compound documents
A compound document is one document whose sections are encrypted under different keys, so the key server decides each section on its own: a clinical summary whose allergies, medications and results are each granted to whoever may see them. The agent names the document and, when it wants one section, the section's id. It never names a key, a t-Tag or a position in the file.
The operator splits the document; the server does not parse it. protect_compound.py reads
operator files -- documents you chose to protect -- not untrusted input. It recognizes two forms:
| source | sections it protects | data types |
|---|---|---|
a C-CDA (<ClinicalDocument) | recordTarget, and every <section> by its LOINC section code (code/@code; its templateId/@root is kept beside it) | demographics, allergies (48765-2), medications (10160-0), problems (11450-4), results (30954-2), vitals (8716-3) |
an HL7 v2 message (beginning with an MSH segment) | every segment by id and occurrence (OBX[2]): PID, PD1, NK1, OBX, NTE | demographics (PID, PD1, NK1), results (OBX, NTE) |
A C-CDA section of any other code, or an HL7 segment other than these and MSH, SFT, ORC and
OBR (which stay in the clear), is refused, never left unencrypted. So is a source carrying a
<!DOCTYPE or <!ENTITY declaration.
python3 mcp/protect_compound.py --config config.json --manifest task-data/manifest.json \
--source mcp/fixtures/synthetic-ccd.xml --document-id ccd-0001 \
--record task-data/seed-record.json
It works in this order:
- It splits the source into a plaintext skeleton, holding one marker per section, and the sections themselves, each an exact byte span of the source. It proves that skeleton plus sections reassemble to the source's own bytes (sha256 equal) before any key is touched.
- As the keys' owner (
--owner, by default the seed's owner), it encrypts each section with a fresh random IV under one key per data type. The first section of a type creates that type's key, and every later one -- in this document or the next -- is encrypted under it, so a lab result in an HL7 message and the results section of a summary are one grant.--key <dataType>=<t-Tag>names an existing key instead. Each encrypt is recorded inprotect-journal.jsonbefore it is sent, and one whose answer was lost is never repeated. - It writes each section's ciphertext to a new
documents/<id>/<section>.v1.bin, the skeleton beside them, and adds the document to the manifest.--recordadds the keys it created to the seed record, soseed.py --cleanupremoves them.
It grants nothing. Granting a section is your call to the Gateway, on that data type's key,
exactly as seed.py grants a document -- never a tool:
POST /api/v1/keys/acl/grant {"ttag": "<the data type's key>", "systemId": ..., "principalId": ..., "rights": ["READ"]}
The manifest's compoundKeys names each data type's key. Any entry on that key lets its user
both read the section and save a new version of it: measured, the key server's decrypt and
encrypt need an entry on the key's access control list, and READ, or WRITE alone, is enough for
both. There is no write-only section.
The grant names documents, never sections; add the two section tools with --tools:
python3 mcp/launch_grant.py --manifest task-data/manifest.json --local-user "$AGENT_USER" \
--documents ccd-0001 --tools list_approved_documents,read_protected_document,read_protected_fragment,save_protected_fragment \
--ttl-seconds 900 --out task-data/grant.json
| tool | arguments | what it does |
|---|---|---|
list_approved_documents | category (optional) | a compound document's entry adds kind: compound and its fragments: each fragmentId, label and data type -- never a path, t-Tag, IV or selector |
read_protected_document | documentId | the mixed view: one decrypt per section, each the key server's decision for this task's user. The text is the skeleton with every section the user may open in place, and a placeholder naming each refused one (an XML comment in a C-CDA, a ZPF segment in HL7). The result lists every section's outcome, complete, and the returned text's sha256 and size |
read_protected_fragment | documentId, fragmentId | one section, as untrusted document content with its provenance |
save_protected_fragment | documentId, fragmentId, text, idempotencyKey | encrypts new text under that section's existing key and saves it as the section's next version (the document's version goes up by one); the same idempotencyKey again returns the first receipt and writes nothing |
One refused section never fails the read. A user granted only results gets the results in
plaintext and a placeholder for everything else, refused by the key server as ACL_DENIED. The
call's audit line records each section's id, outcome, refusing layer and code and its time --
never its text. The refusals before any section is decrypted are the adapter's:
| code | what was refused |
|---|---|
DOCUMENT_NOT_IN_GRANT, UNKNOWN_DOCUMENT | a document the grant does not name; one the manifest does not hold |
NOT_COMPOUND, UNKNOWN_FRAGMENT | a section tool on a single-ciphertext document; a section id the document does not have |
RESULT_TOO_LARGE, NOT_TEXT | a document whose skeleton and sections exceed 64 KiB; a document that is not text |
IDEMPOTENCY_CONFLICT | a save's idempotencyKey already used for other text |
A section the key server refuses is that section's ACL_DENIED in the mixed view, and the whole
call's refusal (isError, layer keyserver) from read_protected_fragment and
save_protected_fragment.
64 keys per grant. The task's scoped session covers every key its documents are under, and
the Gateway caps a session at 64. A grant whose documents and destinations cover more
distinct keys is refused before any Gateway call, GRANT_TOO_WIDE with the count: by
launch_grant.py when you write it, and by the server at start. One key per data type keeps a
document far below the cap; split a task that still exceeds it.
What the server does not do. It does not parse a document: it resolves ids through the manifest, decrypts and reassembles. It does not decide policy: the key server decides every section, for the user the session acts as. It does not create keys: a section save always uses the section's existing key. It does not check that saved text fits the section -- the agent writes what the section will hold.
Handing sections to another user
One user's agent can hand sections that user holds to another user's agent, for one step you defined: a patient's agent hands her history of illness, and not her lab results, to her doctor's agent for a referral. The agent names the step and nothing else. It never names the recipient, a principal, a key or a time.
Steps live in the manifest. You write them, beside the compound documents they hand:
"steps": {
"referral-history": {
"label": "Referral: history of present illness",
"from": "<the delegating user, as the Gateway names her>",
"to": {"label": "Dr. Bob", "actingAs": "<the recipient, as the Gateway names him>",
"systemId": 1234567890, "principalId": "1004"},
"documents": {"ccd-0001": ["problems"]},
"maxWindowSeconds": 1800,
"confirm": "none"
}
}
from and to.actingAs are the names a scoped session's actingAs carries (your service's prefix
and the user). to.systemId and to.principalId are the recipient's ids, which
POST /api/v1/keys/acl/get reports for any entry. Every document a step names must be a compound
document in the manifest, and every data type one of its sections'. The server refuses to start on
a manifest that breaks either rule.
Name the steps a task may hand in its grant, and add the two tools with --tools:
python3 mcp/launch_grant.py --manifest task-data/manifest.json --local-user "$PATIENT_USER" \
--documents ccd-0001 --tools list_approved_documents,read_protected_document,hand_off,close_step \
--steps referral-history --ttl-seconds 1800 --out task-data/grant.json
launch_grant.py refuses a step the manifest does not define, and either tool without --steps.
The recipient's agent runs under its own grant, naming the document, the read tools and no save
tool and no step.
| tool | arguments | what it does |
|---|---|---|
list_approved_documents | category (optional) | adds steps: each step's id, label and what it would hand of this task's documents, whether it is open and until when -- never who hands it, who receives it, or a principal id |
hand_off | stepId | grants the step's recipient READ on each of the step's sections this task's user holds, until the step's window ends. Returns a receipt: the step, its label, the recipient's label, the sections handed and expiresAt. Asked again while the step is open, it returns the first receipt and grants nothing |
close_step | stepId | revokes every entry that hand-off made, each read back absent. Asked again, it returns the first receipt |
The four rules, and what enforces each.
| rule | where it is enforced |
|---|---|
| Only what the user has. | The server hands only the step's sections in documents this task's grant names. Each entry is then granted through the user's own scoped session, so the key server grants it only if that user holds WRITE or OWNER on the section's key, and its audit names the user who handed it. If the key server refuses any one key, the entries already made are revoked and read back absent: nothing is handed. |
| No longer than the user's grant. | Each entry's endTime is the earlier of now plus the step's maxWindowSeconds and the grant's notAfter, less the Gateway clock's measured offset. The key server enforces that time on every use. |
| Not passed on. | The entry is READ, and the key server refuses a READ holder's own grant ("does not have write privilege"). Only the step's from user may call either tool. |
| Ended when the step closes. | close_step revokes. A call refused GRANT_EXPIRED, and the server's own shutdown, revoke the open hand-offs they still can. Past that, the entry's endTime ends it at the key server, and close_step.py revokes it now. |
A handed section also lets its recipient save under its key. Any entry on a key lets its user
both decrypt and encrypt under it, so a READ entry is enough to save a new version. The
recipient's grant is what keeps the section read-only: over stdio, it names no save tool. Over
Streamable HTTP, where one grant serves every caller, the server refuses
save_protected_fragment by the recipient of an open hand-off on the sections handed to them,
HANDED_OFF_READ_ONLY.
The server only ever updates or revokes an entry that it made. If the recipient already holds
an entry on one of the keys for any other reason, the hand-off is refused, RECIPIENT_HAS_ENTRY,
and that entry is never touched.
| code | layer | what was refused |
|---|---|---|
STEP_NOT_IN_GRANT | adapter | a step this task's grant does not name |
UNKNOWN_STEP | adapter | a step the manifest does not define |
CONFIRMATION_UNAVAILABLE | adapter | a step whose confirm is elicit (see below) |
NOT_STEP_DELEGATOR | adapter | a user who is not the step's from |
NOTHING_TO_HAND_OFF | adapter | none of the step's documents is in this task's grant |
WINDOW_TOO_SHORT | adapter | less than four seconds left in the window |
RECIPIENT_HAS_ENTRY | adapter | an entry on one of the keys that the server did not make |
HANDOFF_NOT_VERIFIED | adapter | an entry did not read back as READ, with the endTime sent and unlimited uses. Every entry made was revoked |
STEP_NOT_OPEN | adapter | close_step on a step that is not handed off |
HANDED_OFF_READ_ONLY | adapter | the recipient saving a section handed to them |
ACL_DENIED, ACCESS_DENIED | keyserver | the key server refused the user's read or grant on a section's key: nothing was handed |
Every adapter refusal above leaves nothing sent to the Gateway's crypto or key routes.
When the delegating user is away. close_step.py revokes a step's open hand-offs from the
server's journal. It signs in as your registered service, like seed.py, and acts as the step's
from user:
python3 mcp/close_step.py --config config.json --manifest task-data/manifest.json \
--journal task-data/manifest.journal.sqlite --step referral-history
It revokes only entries the journal names, reads each back absent, and ends CLOSE-STEP: DONE.
Confirmation is not asked. This server does not ask the host to confirm a hand-off with its
user (MCP elicitation), and the four rules do not depend on any host's interface. A step whose
confirm is elicit is refused by name. At every initialize, the audit file records the host's
name and whether it declared it can elicit, in one line {"event": "client", ...}.
Removing access, and what that does not do
To take a right away, remove the user's entry from the key's access control list. The owner
calls POST /api/v1/keys/acl/revoke with the key's t-Tag and the user's system and principal
ids. The next call is refused by the key server, both on the handle the agent already holds
and on any fresh run, with ACL_DENIED.
The scoped session itself ends at the earliest of these:
- when the server's standard input closes;
- on
SIGTERMorSIGINT; - after an internal failure;
- on the first call after the grant expires (that call is refused first);
- after five minutes. The server mints a fresh one only while the grant is still valid.
To take a key away from everyone, its owner can expire it (POST /api/v1/keys/expire): the
key's record is kept, and every later use of it -- by any user, on any path -- is refused
410 KEY_EXPIRED, which the tools report as the key server's KEY_EXPIRED ("the key was expired;
it can never be used again"). An expiry is never undone. Or remove it
(POST /api/v1/keys/remove): the key and its access control list are deleted from the key
server, and every later use is refused KEY_NOT_FOUND. Removal is local to that key server: a
copy of the key exported earlier is not reached.
Removing a right stops future access through this path. It does not take back text already returned to the model.
What this does not establish
- One user over stdio; many over loopback HTTP. Over stdio the server acts for the one user its
grant names. Over Streamable HTTP it serves many users, each as themselves, but on
127.0.0.1only: putting it behind a network-facing proxy is a deployment this page does not cover. - The process is trusted with the service. Anything that controls the server process can use the registered service's authority. The narrowing applies to what the model can reach.
- Not a Gateway proxy. The nine tools are the whole surface. Tool calls reach
POST /api/v1/crypto/decryptandPOST /api/v1/crypto/encrypt, and onlyhand_offandclose_stepreach the key ACL routes (/api/v1/keys/acl/get,grant,updateandrevoke), on the keys of the task's own documents and for the step's recipient alone.save_protected_resultalways encrypts under a destination's existing key, andsave_protected_fragmentunder a section's. Onlyencrypt_file, under a grant that lists it, encrypts without one, which creates a new key. - No confirmation from the user. A hand-off happens when the agent calls
hand_offon a step the grant names. The server does not ask the host to confirm it. - No host-name check on the key server. The Gateway verifies the key server's certificate chain against its configured anchor. That is the trust step, and this server adds nothing to it.
Clean up
seed.py --cleanup removes the seed's keys -- each deleted from the key server and read back
absent, so the seed record lists none left -- and removes its two principals:
python3 mcp/seed.py --config config.json --cleanup task-data/seed-record.json