Skip to main content

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:

toolargumentswhat it does
list_approved_documentscategory (optional)the document ids this task is approved to read, their non-sensitive labels, and the destinations it may save to
read_protected_documentdocumentIddecrypts 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_resultdestinationId, text, idempotencyKeyencrypts 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):

toolargumentswhat it does
encrypt_filepathencrypts 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_filepathdecrypts 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:

layerwhat decideswhat 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
gatewaya 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)
keyserverevery decrypt and encrypt, for the user the session acts as: its roles, then the key's access control list -- every timeACL_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-config and this configuration alone;
  • no built-in tool (--tools "");
  • --allowedTools naming 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:

  • runId and grantId;
  • actingAs;
  • the session's public key thumbprint jkt and a 12-hex sha256 prefix of the session handle;
  • tool, documentId and version;
  • outcome, and for a refusal the refusing layer, its code and status;
  • 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.py writes the standard mcpServers JSON (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 the clean_env.py launch.
  • Keep the launcher. The server starts through clean_env.py whatever 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:

  1. 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 401 with WWW-Authenticate: Bearer ... resource_metadata="...", and the server publishes its protected-resource metadata at https://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.
  2. 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.
  3. 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.
  4. 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_access for 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 adds offline_access to 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 the offline_access client 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:

  1. its first request to /mcp is answered 401, and it reads the resource_metadata URL the answer names;
  2. it reads the server's protected-resource metadata, and then the provider's authorization-server metadata (/.well-known/oauth-authorization-server/<issuer path>);
  3. 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 as resource. Keycloak ignores resource unless its resource-indicators feature is on, so the token's audience comes from the audience mapper;
  4. 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.

  1. Copy the sign-in link Claude Code shows.
  2. 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.
  3. 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:

refusallayerwhat 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_REFUSEDadapterthe 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_REFUSEDgatewaythe Gateway knows no user of this service for that identity, or did not accept the exchanged token; final
IDP_UNAVAILABLEgatewaythe 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:

classwhat was refused
no-tokena request with no Authorization: Bearer token: a host that has not signed its user in yet (its first request always is one)
expireda token past its expiry: the user signs in again
not-yet-valida token whose validity starts later (check the clocks)
wrong-audiencea token not issued for this server: its audience does not name this server's client, or it has none
wrong-issuera token from another issuer than the configured provider
bad-signaturea token whose signature does not verify under the provider's key
unknown-keya token signed by a key the provider does not publish
not-an-access-tokenan ID token, or another token that is not an access token
malformedsomething that is not a token at all
claim-missinga token missing a claim this server requires
invalidany other token this server would not accept (for example a signing algorithm it does not allow)
provider-unavailablethe 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 maxCallers users 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 SIGTERM or SIGINT every user's session is revoked and every holder key destroyed.

Saves​

save_protected_result works in this order:

  1. It reserves its idempotencyKey in a small journal (<manifest>.journal.sqlite by default), before asking the Gateway to encrypt.
  2. It writes the ciphertext to a new versioned file, results/<destination>.v<n>.bin, never over an existing one.
  3. 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:

codewhat was refused
OUTSIDE_ROOTa 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_CHANGEDno 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_LARGEa .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_HEREan 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:

sourcesections it protectsdata 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, NTEdemographics (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:

  1. 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.
  2. 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 in protect-journal.json before it is sent, and one whose answer was lost is never repeated.
  3. 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. --record adds the keys it created to the seed record, so seed.py --cleanup removes 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
toolargumentswhat it does
list_approved_documentscategory (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_documentdocumentIdthe 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_fragmentdocumentId, fragmentIdone section, as untrusted document content with its provenance
save_protected_fragmentdocumentId, fragmentId, text, idempotencyKeyencrypts 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:

codewhat was refused
DOCUMENT_NOT_IN_GRANT, UNKNOWN_DOCUMENTa document the grant does not name; one the manifest does not hold
NOT_COMPOUND, UNKNOWN_FRAGMENTa section tool on a single-ciphertext document; a section id the document does not have
RESULT_TOO_LARGE, NOT_TEXTa document whose skeleton and sections exceed 64 KiB; a document that is not text
IDEMPOTENCY_CONFLICTa 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.

toolargumentswhat it does
list_approved_documentscategory (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_offstepIdgrants 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_stepstepIdrevokes every entry that hand-off made, each read back absent. Asked again, it returns the first receipt

The four rules, and what enforces each.

rulewhere 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.

codelayerwhat was refused
STEP_NOT_IN_GRANTadaptera step this task's grant does not name
UNKNOWN_STEPadaptera step the manifest does not define
CONFIRMATION_UNAVAILABLEadaptera step whose confirm is elicit (see below)
NOT_STEP_DELEGATORadaptera user who is not the step's from
NOTHING_TO_HAND_OFFadapternone of the step's documents is in this task's grant
WINDOW_TOO_SHORTadapterless than four seconds left in the window
RECIPIENT_HAS_ENTRYadapteran entry on one of the keys that the server did not make
HANDOFF_NOT_VERIFIEDadapteran entry did not read back as READ, with the endTime sent and unlimited uses. Every entry made was revoked
STEP_NOT_OPENadapterclose_step on a step that is not handed off
HANDED_OFF_READ_ONLYadapterthe recipient saving a section handed to them
ACL_DENIED, ACCESS_DENIEDkeyserverthe 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 SIGTERM or SIGINT;
  • 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.1 only: 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/decrypt and POST /api/v1/crypto/encrypt, and only hand_off and close_step reach the key ACL routes (/api/v1/keys/acl/get, grant, update and revoke), on the keys of the task's own documents and for the step's recipient alone. save_protected_result always encrypts under a destination's existing key, and save_protected_fragment under a section's. Only encrypt_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_off on 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