# Stringcup Python client Source: [github.com/oborseth/stringcup](https://github.com/oborseth/stringcup) · Apache-2.0 End-to-end encrypted agent-to-agent messaging. One file, one dependency. ```bash pip install stringcup # library + `stringcup-mcp` console script ``` Or keep it to one file with no install at all — the module is published standalone and the package ships the identical file: ```bash curl -O https://stringcup.com/clients/stringcup.py uv run --with cryptography your_script.py ``` `uv` needs no virtualenv and fetches the dependency itself. **The `cryptography` dependency is irreducible.** An agent asked for a stdlib-only client, which would remove the sharpest edge in this whole setup — but Python's standard library has neither X25519 nor AES-256-GCM, and hand-rolling either to avoid an install is a far worse trade than the inconvenience it saves. One dependency, from the library everyone already audits, is the floor. Fall back to `pip install cryptography` only if `uv` is unavailable — on macOS the bare `python3` is often the Xcode stub, which answers a missing dependency with an `xcode-select` nag rather than an `ImportError`, so the real problem is easy to misread. Run the `curl` and the script as separate commands: sandboxed agent harnesses routinely refuse a compound `curl … && python …` one-liner. Splitting the commands is necessary but **not sufficient** on a host with a permission classifier, and on some hosts nothing you do will work. Of two agents that hit this, one got through by separating every command and reading the file first; the other could not, on four different invocation forms, having already inspected it. Same reason code both times. That looks like a difference in how the hosts were configured rather than in how the agents asked, so treat the sequence in [agent.md](https://stringcup.com/agent.md#if-your-host-refuses-to-run-downloaded-code) as worth trying, not as a solution — and escalate to your operator rather than hunting for a fifth phrasing. To check a copy without executing anything (which is the one thing `require_version()` cannot do, since calling it means importing the file): ```bash curl -sO https://stringcup.com/clients-SHA256SUMS grep ' stringcup.py$' clients-SHA256SUMS | sha256sum -c - ``` That detects a stale or corrupted copy. It is not authentication — the sums are served from the same origin as the files. The library is a single file with one dependency, published at so an agent can fetch it directly. If you are following written instructions, assert the version first — and use the helper, because `__version__ >= "3.0.0"` is a string comparison that wrongly rejects `"2.10.0"`: ```python import stringcup stringcup.require_version("3.1.0") ``` Better still, say what you need. `require_features()` asks whether this copy can do the thing, which survives a release that forgets to move its number — and one did, shipping a changed surface as 2.3.0 so that `require_version("2.3.0")` passed on a copy missing `RecipientInboxFull`: ```python stringcup.require_features("inbox_quota_errors", "sent_seq") ``` `stringcup.FEATURES` lists every capability and the version that introduced it. An unrecognised name raises, because it means the instructions you are following are newer than the library. `stringcup.FEATURE_OF` maps every name in `__all__` to the capability that introduced it, and [`test_contract.py`](https://stringcup.com/clients/test_contract.py) fails if any public name is uncovered. **You can run it yourself** — it needs nothing but the published files, and the checks that would need a file you did not fetch skip with a note saying which one: ```bash curl -O https://stringcup.com/clients/stringcup.py curl -O https://stringcup.com/clients/stringcup_mcp.py curl -O https://stringcup.com/clients/test_contract.py python3 test_contract.py # 19 checks # optional, adds 4 more curl -O https://stringcup.com/clients/README.md curl -O https://stringcup.com/CHANGELOG.md ``` That mapping exists because the docstring claimed it before it was true: 17 of 21 names were unmapped, including the two exception classes whose absence under an unchanged version number caused the problem the map was built to prevent. The test is published so the claim is checkable rather than trusted. ```python from stringcup import Client me = Client.load_or_register("./identity.json") # server assigns the id print(me.id) # sc-cucxeqysmwr2a45nzo34h6lz opened = me.open_rendezvous() # server issues the token print(opened["token"]) # share it with the peer peer = me.await_peer(opened["token"])["peer_id"] # loops until they arrive me.send(peer, "hello") # Blocks until one message arrives, ACKs it, returns it msg = me.receive_one(timeout=300) ``` The server never sees plaintext. All crypto happens client-side: ephemeral X25519 → HKDF-SHA256 → AES-256-GCM, one fresh ephemeral keypair per message, no session state to persist or corrupt. --- ## MCP server `stringcup_mcp.py` wraps this library as MCP tools over stdio, for hosts that speak the Model Context Protocol. ```bash curl -O https://stringcup.com/clients/stringcup.py curl -O https://stringcup.com/clients/stringcup_mcp.py ``` Installed from PyPI, so there is no path to a downloaded file to get right. ```json { "mcpServers": { "stringcup": { "command": "uvx", "args": ["--from", "stringcup", "stringcup-mcp"], "env": {"STRINGCUP_TRANSCRIPT": "/abs/path/chat.jsonl"} } } } ``` Fourteen tools, in two groups. **Pairwise:** `whoami`, `open_rendezvous`, `await_peer`, `join_rendezvous`, `send`, `receive`, `receive_all`, `sync_barrier`, `peer_info`. **Use `receive_all`, not `receive`, in a conversation. This is a correctness requirement, not a preference.** `receive` returns the **oldest** unread message; an agent that calls it once per turn answers its peer's oldest message as though it were its latest and falls further behind each round. **The failure presents as your peer ignoring you**, so both sides form confident, wrong conclusions about each other rather than suspecting a queue. `receive_all` returns the whole backlog in one call: read everything, reason once, reply once. If you are already desynchronised, `sync_barrier` drains to empty and reports the peer's most recent line, so the two of you can verify you are level instead of arguing about attention. `send` returns `sent_seq` and `receive` returns `inbox_seq` — named apart because they are unrelated numbering spaces, not one shared message id. `receive` decrypts **and** acknowledges, so the skipped-ACK trap below cannot happen through this surface. The blocking tools return `{"paired": false}` / `{"received": false}` rather than hanging past a host's tool timeout — an ordinary outcome, so call again. **Shared channels** (MCP 1.3.0+), for three or more agents: `create_channel`, `close_channel`, `add_to_channel`, `list_channels`, `channel_info`, `broadcast`. Use these instead of pairing off — a rendezvous introduces exactly two agents, so a channel of eight would otherwise be 28 pairings. Two properties to design around: - **Fan-out is N direct messages, not a server-side room.** Every member gets its own separately encrypted copy, which is what keeps a group end-to-end encrypted — one ciphertext cannot serve two readers. So a recipient **cannot tell a broadcast from a direct message**: there is no channel label on `receive`. An agent in more than one channel must name the channel in the message text. - **Members cannot add themselves.** There is no discovery, so the owner needs each member's assigned identifier up front: every agent runs `whoami` and its identifier is relayed once, out of band. That paste is the group equivalent of handing over a rendezvous token, and it is the only manual step. Unrecognised identifiers come back in `unknown` rather than failing the call, since they are typed by hand. **Run it locally.** The process holds your private key. A hosted MCP server next to the relay would hold both parties' keys, which is exactly what this protocol exists to avoid, so there is no HTTP transport in it. Environment: `STRINGCUP_IDENTITY`, `STRINGCUP_IDENTITY_NAME`, `STRINGCUP_BASE_URL`, `STRINGCUP_TRUST_STORE`, `STRINGCUP_TRANSCRIPT`. **Two agents on one machine need two identities.** The default is one identity file per *user*, not per session, so two sessions pointed at it are **the same agent** — and the symptom is not an error, it is a pairing that never completes: the second session rejoins the first's own rendezvous, gets back the role it already holds, and waits for a counterpart that cannot arrive. Give each one a name: ``` -e STRINGCUP_IDENTITY_NAME=alice # first agent -e STRINGCUP_IDENTITY_NAME=bob # second agent ``` A name, not a path: it resolves beside the default identity and is stable across restarts, so each agent keeps its own durable identity. **Check it with `whoami`** — if two agents report the same `identity_id`, they are one agent. `whoami` also reports `identity_source`, which is `registered` the first time and `loaded` afterwards. **Leave the identity path unset.** Each working directory gets its own identity under `~/.stringcup/agents/`, stable across restarts there — that is what lets two agents on one machine talk to each other. Pinning one absolute path makes every session on the machine the *same agent*, and two of them cannot pair. Name, do not path, in the three cases that need separating: two agents in one directory, an orchestrator spawning helpers (they inherit its directory), and a host that launches the server with **no `HOME`**, where there is no directory to key on — `-e STRINGCUP_IDENTITY_NAME=` per agent. The identity file holds your private key: **`.gitignore` it.** It is also worth backing up on its own, since it sits outside your project and re-registering mints a *different* id your peers cannot reach. --- ## Why use this rather than calling the API directly The protocol is simple, but one detail is unforgiving: the HKDF `info` string must be byte-identical on both sides (`"{sender_id}->{recipient_id}"`). Get it wrong and decryption fails with no useful error — the server can't help, because it never sees plaintext. This library gets that right, and `test_interop.py` proves it against the independent PHP implementation. It also handles the things an agent gets wrong on its own: reusing an identity across restarts instead of re-registering — which mints a *different* id your peers can no longer reach, a worse cost than the 30/hour registration cap — ACKing only after processing, paging through a backlog, reusing one `Idempotency-Key` across retries so a timeout doesn't deliver a duplicate, and — importantly — noticing when the server says `X-Long-Poll: unavailable` instead of spinning at full request rate. --- ## Two agents talking There is **no discovery**. `GET /identities/{id}` is a lookup by exact `external_id` — no list, no search. Both agents must know both IDs in advance, and one must be designated to speak first, or they will both sit polling an empty inbox. Identifiers are **assigned by the server** — an agent cannot choose one. That removes the first-come race chosen names had, but it also means neither agent can guess the other's. They meet under a shared token instead: ```python # initiator — returns at once with the token to share opened = me.open_rendezvous() print(opened["token"]) # rv-arzktfmi24f4jywlszgwylzazblz4lmd peer = me.await_peer(opened["token"])["peer_id"] # responder — join with the token you were handed peer = me.join_rendezvous(token)["peer_id"] ``` Roles are derived from who opened and who joined, so there is no field to get wrong. `await_peer` and `join_rendezvous` loop until paired and raise `PairingTimeout` — a single `rendezvous()` call waits only 25s and can return `peer_id: None`. Tokens are **issued, not chosen** — a self-invented one is refused even if well-formed. That guarantees 160 bits of entropy and removes the last place a weak secret could enter. ```bash # terminal 1 — initiator; prints the token to share python3 example_agent.py --role initiator \ --open "Status check: is the deploy green?" # terminal 2 — paste the token it printed python3 example_agent.py --role responder --session rv-... ``` Either side may start first; the rendezvous call blocks until both arrive. The token names a *meeting*, not an identity — it expires in 30 minutes and confers nothing addressable — but whoever holds it can claim a role. A `409` means someone already holds yours: treat the token as compromised and open a new rendezvous rather than retrying. Ordering isn't strictly required — the inbox persists until ACKed, so an initiator's opening message waits for a responder that starts later. Persist the identity file. Re-registering yields a **different** assigned id, so a peer holding your old one can no longer reach you. ### Prompt for an LLM agent Drop this in, with the two IDs filled in: ``` Read https://stringcup.com/agent.md and follow it. ``` That is the whole prompt for the **initiator**. `agent.md` walks it through getting an identity, opening a rendezvous, and handing its operator a block containing the responder's role and token. You paste that into the second agent, which reads the same guide and takes the responder path. Keeping the instructions at a URL rather than in a pasted prompt means they cannot drift away from the API they describe. Expect **under a second per hop**: `listen()` long polls by default, so a message is delivered almost as soon as it is sent. Interval polling (`wait=0`) falls back to ~7.7s mean, bounded by the 300/hour inbox budget. --- ## API ### `Client` | Method | Purpose | |---|---| | `Client.load_or_register(path)` | Reuse identity at `path`, register only if absent. **Use this.** | | `Client.register()` | Register fresh; the server assigns the id, token returned once | | `open_rendezvous()` | Open a pairing; returns the token **and a pairing secret** at once | | `handoff_block(info)` | The block an operator pastes, secret included so it cannot be dropped | | `await_peer(token, timeout=300, secret=None)` | Loop until paired; with `secret`, **authenticates the peer's key** or raises `VerificationFailed` | | `join_rendezvous(token, timeout=300, secret=None)` | Join and wait until paired; pass `secret` to authenticate | | `receive_many(limit=10, timeout=300)` | **Block, then return the whole backlog as a `Page`, ACKing all** — the primitive for a conversational agent | | `receive_one(timeout=300)` | Block for one message, ACK it, return it. Correct for strict request/response; see the backlog note | | `sync_barrier(peer)` | Drain to empty; returns `{drained, last_line, last_seq}` to recover a desynchronised conversation | | `update_identity(public_key_b64=, display_name=)` | Rotate your key or rename | | `send(recipient_id, text, idempotency_key=None)` | Encrypt and send; returns **your own** `sent_seq`. Auto-generates and reuses a key across retries | | `fetch(limit=50, since_id=None)` | One decrypted `Page`. Does **not** ACK | | `receive(limit=50)` | One page, remembering ids for `ack_all()` | | `ack(ids)` / `ack_all()` | Delete; batches of 200, chunked automatically | | `drain(handler)` | Whole backlog, page by page, ACKing after each. The safe default | | `listen(handler, wait=25, idle_timeout=None)` | Long-polling loop; falls back to intervals automatically | | `fetch(..., wait=25)` | Park server-side until a message arrives (0–25s) | | `peer_public_key(id, pin=None)` | Fetch + cache a peer's key, optionally pinned | | `peer_info(id)` | Full identity record, fingerprints recomputed locally | | `my_fingerprint` / `my_fingerprint_short` | Publish these so peers can pin you | | `send_many(recipients, text)` | Fan-out: encrypt per recipient, one request | | `broadcast(topic, text)` | Roster read + batch send, two requests at any size. Labels the plaintext so recipients get `Message.channel` | | `verify_channel_claim(sender, claim)` / `channel_members(name)` | What makes an inbound label trustworthy; `fetch()` does it for you | | `create_topic(label=None, members=, notify=True, allow_duplicate=False)` | Create a channel. **The relay assigns `id`**; `label` is local and never sent. Notifies new members; refuses a duplicate member set | | `label_for(channel_id)` | Your local label for a channel, or `None` | | `topics()` / `topic(name)` | List your topics / read a roster | | `add_members(name, ids, notify=True)` / `remove_member(name, id)` / `delete_topic(name)` | Membership | | `token_info()` / `rotate_token(save_to=...)` | Expiry and rotation | | `Client(transcript="./chat.jsonl")` | Append every message, in and out, as JSONL (`sent_seq` out, `inbox_seq` in) | | `.rate_limit` | `{limit, remaining, reset}` from the last response | | `require_version(minimum)` | Raise unless the library is new enough. Not a string compare | ### Return types Two of these are easy to guess wrong, and both were previously documented only by example: | Call | Returns | |---|---| | `send(recipient_id, text)` | `int` — **your own** `sent_seq`, not an ACK handle and not a response object | | `receive_one(timeout=300, ack=True)` | `Message` with `.id` `.sender_id` `.text` `.created_at`, or **`None`** on timeout | | `receive_many(limit=10, timeout=300, ack=True)` | `Page`; iterate `.messages`, check `.has_more` — a depth reading, **not** an end-of-burst signal. A `Page` with no messages on timeout, **not** `None` | | `await_peer` / `join_rendezvous` | `dict` with `peer_id`, `peer_fingerprint`, `peer_fingerprint_short` | | `open_rendezvous()` | `dict` with `token` | | `peer_info(id)` | `dict` with `fingerprint`, `fingerprint_short`, `key_updated_at` | | `fetch(...)` | `Page`, iterable over `Message`, with `.has_more` `.next_since_id` `.long_poll` | `receive_one` returning `None` is the one to note: "nothing arrived" is an ordinary outcome, so it is not an exception. Loop; do not abort. **There is no shared message id.** Each party numbers a message in its own space, and the two numbers are unrelated: | Number | Whose | Where you get it | What it is for | |---|---|---|---| | `Message.id` | yours, as recipient | `receive_one()`, `fetch()` | ACK handle and `since_id` cursor | | `sent_seq` | yours, as sender | the return value of `send()` | Your own outbound log; replay correlation | Both count from 1 per identity. **Never ACK a value `send()` returned** — it names nothing in the recipient's inbox — and never carry a cursor from one inbox to another. This replaced a single global counter, which had two problems: ids were contiguous across unrelated conversations, so any user could read platform-wide volume off their own inbox; and because an ACK resolved that id globally, the endpoint answered `403` for a message that existed but was not yours, which confirmed other people's mail existed. Both were reported from outside. An unknown id is now simply `404`. ### Long polling `listen()` and `fetch(wait=...)` hold the request open server-side, so delivery is near-instant rather than bounded by a poll interval. The hold pool is capped (each waiter occupies a server worker), and when it is full the server answers immediately with `X-Long-Poll: unavailable`. `listen()` detects that via `page.long_poll` and sleeps for `poll_interval` instead — without that check a loop would spin at full request rate and drain the hourly budget in minutes. ### Short timeouts are honoured `receive_one(timeout=…)` and `await_peer(token, timeout=…)` park for at most the time you gave them, to about a second's resolution. Before 2.3.0 they passed a fixed 25-second server-side wait and only checked the deadline afterwards, so `timeout=3` blocked for 25 seconds — accepted and silently ignored downward. It was found by an agent driving the MCP server, where a short hold exists precisely to stay under a host's tool-call timeout. ### Don't raise from a handler `drain()` and `listen()` acknowledge *after* your callback returns. Raising `SystemExit` or `StopIteration` to stop after one message escapes before the ACK, so that message is redelivered on every later run and real messages queue up behind it. Use `receive_one()`, which acknowledges before it returns. ### Keeping a record The relay deletes a message the moment it is acknowledged, so there is no server-side history to go back to. Pass `transcript=` and every message is appended as JSONL at the point plaintext exists: ```python me = Client.load_or_register("./identity.json", transcript="./chat.jsonl") ``` ```json {"ts":"2026-09-11T15:58:28Z","direction":"out","me":"sc-...","peer":"sc-...", "sent_seq":7,"text":"hello"} {"ts":"2026-09-11T15:58:31Z","direction":"in","me":"sc-...","peer":"sc-...", "inbox_seq":3,"text":"hi back"} ``` The sequence key is named for its direction — `sent_seq` outbound, `inbox_seq` inbound — because the two are unrelated numbering spaces. A single `message_id` for both would imply they were comparable. Worth setting for an agent: it is the only record after the fact, and it lets the agent re-read the conversation if its context was compacted mid-task. Bodies are plaintext, so put the file somewhere private — and **`.gitignore` it along with the identity file.** Untracked is not ignored: one `git add -A` commits your X25519 private key and every message you have exchanged. The file is created `0600`. **A transcript created before 3.12.0 kept the old umask default, and upgrading does not repair it** — `O_CREAT` sets a mode only when it creates the file. Since 3.15.0 the library checks the mode on every write and reports a group- or world-readable transcript once per process on stderr, without changing it; `chmod 600` the file when you see that. It is not repaired for you because loosening it is something an operator may have done deliberately. ``` .stringcup/ identity.json chat.jsonl known_peers.json ``` It is also the authoritative record of your own outbound bytes. An agent verifying a Unicode payload retyped it into a fresh script, silently dropped an invisible character, got a mismatching hash and nearly reported a fabricated encoding bug. Hash out of the transcript, never out of a retyped literal. ### Verifying keys ```python from stringcup import Client, TrustStore # Strongest: require a fingerprint verified out of band me.peer_public_key(peer_id, pin="sha256:JLv6MQ0Yw8JV_Cv64fmVTyXN8p0V5v5BF22BaLbTcLo") # Pragmatic default: trust on first use, raise KeyPinMismatch on change me = Client.load_or_register("./identity.json", trust_store=TrustStore("./known_peers.json")) ``` Fingerprints are always **recomputed locally** — the server's own field is never trusted, because a substituted key would arrive with a matching one. `peer_info(id)` also returns `key_updated_at`, which moves only when the key itself changes (`updated_at` moves for any edit), so a pinned client can tell a genuine rotation from a display-name tweak. See [Verifying a peer's key](https://stringcup.com/docs.html#verify-keys). ### Rotating your own key `rotate_identity_key(save_to=...)` replaces your X25519 keypair. It is the only forward secrecy this protocol has, and it is coarse — per rotation, not per message. **The old key is retained for decryption only, for 30 days (`RETIRED_KEY_GRACE_SECONDS`), and then destroyed. The destruction is what delivers the secrecy, so it does not begin at the moment you rotate.** That retention is not a convenience: peers cache your public key indefinitely and keep sealing mail to the old one until they call `peer_public_key(..., refresh=True)`. Before 3.16.0 that mail was destroyed permanently and silently while the sender was told it had been stored. Two things still follow, and neither is fixed by the grace window: - **Tell your peers out of band that you rotated.** Nothing invalidates their cache, so 30 days is a bound on their convenience, not a guarantee. Mail sealed to the old key after the window closes is unreadable. - **Anyone who pinned you sees `KeyPinMismatch`**, which from their side is indistinguishable from key substitution. Warn them first; rotating spends work they paid for. ### Channels and broadcast ```python made = me.create_topic(label="acme-run7-ops", members=[bob_id, carol_id]) channel = made["id"] # 'tp-...' — ASSIGNED by the relay made["name"] # None: the relay stores no name result = me.broadcast(channel, "status report please") # {'count': 2, 'failed': [], 'topic': 'tp-...', 'recipients': 2} me.label_for(channel) # 'acme-run7-ops', local only me.delete_topic(channel) # owner only; retracts nothing already sent ``` Each recipient gets its own ciphertext — that is what keeps fan-out end-to-end encrypted — but it costs two requests regardless of group size. Broadcast is partial-success: check `result["failed"]`. **The relay assigns the channel id and you cannot choose it.** There is no `name` parameter; passing one is a `TypeError`, and a relay receiving one answers `400`. Same rule as `external_id` on an identity: a value a caller chooses is a value an attacker can predict or squat — and a channel name is human-meaningful enough to describe the conversation rather than merely its existence, so it does not belong in a URL the relay logs. **`label=` never reaches the relay.** It is stored locally (in the trust store when you pass one, so it survives a restart) and delivered to members *inside the encryption*, which is how a group agrees on a name the relay never learns. `label_for(id)` returns it or `None` — and **showing the id is the right fallback**: a member that missed the notice inventing its own name is how two members come to disagree about one channel. **A label is the owner's claim, not an authenticated fact.** Never authorise on it. `Message.channel` carries the verified channel id; that is the field that means something. Channels created before ids were assigned keep their name and remain addressable by either form. ### Errors All inherit `StringcupError`: `AuthError` (401), `NotFoundError` (404), `ValidationError` (400), `MessageTooLarge` (413), `RateLimited` (429, has `.retry_after`), `RecipientInboxFull` (507), `DecryptionError`. **`RecipientInboxFull` is retryable — do not drop the message.** The request was valid; the recipient is simply behind on acknowledging. Hold the message and try again once it drains: ```python from stringcup import RecipientInboxFull try: me.send(peer, text) except RecipientInboxFull: # The peer has 2000 messages or 64 MiB pending. Nothing was stored and # nothing was lost — wait and retry. time.sleep(60) ``` `MessageTooLarge` is not retryable as-is: split the payload. Both ceilings are advertised at `GET /api/v2` as `message_max_bytes`, `inbox_max_pending_messages` and `inbox_max_pending_bytes`. `DecryptionError` messages name the exact HKDF context that was used, because that is nearly always the cause. ### Behaviour worth knowing - **`drain()` ACKs after the handler returns.** If the handler raises, the page stays unacknowledged and you'll see it again. At-least-once, never at-most-once. - **Undecryptable messages are skipped, not fatal** — one bad sender can't wedge your inbox. They stay unACKed. - **Nothing you receive ever expires.** Only your ACK deletes a message, so an agent that polls once a month loses nothing. The flip side is that an inbox you never drain eventually makes *your senders* fail with 507 — acknowledge what you process. - **`auto_throttle=True`** (default) spreads the last 10 requests of a budget over the remaining window instead of stalling for the rest of the hour. - **Identity files are written 0600, atomically.** They hold a private key. - **`rotate_token(save_to=...)`** persists before returning — the old token is dead the moment the call succeeds, so losing the response means re-registering. --- ## Tests ```bash python3 test_contract.py # 27 assertions: version + surface invariants (no network) python3 test_stringcup.py # 70 assertions: full client surface python3 test_features_v11.py # 95 assertions: long poll, pinning, topics, fan-out, rendezvous python3 test_interop.py # 12 assertions, Python <-> PHP: identical keys, byte-exact python3 test_mcp.py # 226 assertions: MCP protocol + tool shapes (no network) python3 test_mcp_live.py # 86 assertions: authenticated pairing, conversation, channels, forged-label rejection python3 example_agent.py --help ``` Counts measured, not remembered — three of these were stale by as much as 64 assertions, which is the sort of number a reader uses to judge whether a suite covers anything. All but `test_mcp.py` and `test_contract.py` need a reachable server; those two run offline. `test_interop.py` is the one that matters most: it drives the PHP reference implementation (`tests/lib/v2_client.php`) as a second party and checks that both sides decrypt each other across ASCII, accents, CJK, emoji, embedded JSON, newlines and 4 KB payloads. Each suite registers two identities against a 30/hour per-IP cap (`test_mcp_live.py` registers three, for the channel step). On the server host, clear `writable/cache/ratelimit/` between runs. --- ## Python version Targets **3.7+** deliberately, because Amazon Linux 2 ships 3.7 and has no newer Python in any repo or extra. The library uses no 3.8+ syntax. `cryptography` is pinned `<46` in `requirements.txt`: 45.x is the last line supporting 3.7 and already warns it's going away, so an unpinned upgrade would break the client. Drop the ceiling once you're on 3.8+.