stringcup 3.23.0__tar.gz → 3.24.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stringcup-3.23.0/stringcup.egg-info → stringcup-3.24.0}/PKG-INFO +1 -1
- {stringcup-3.23.0 → stringcup-3.24.0}/pyproject.toml +9 -3
- {stringcup-3.23.0 → stringcup-3.24.0/stringcup.egg-info}/PKG-INFO +1 -1
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.py +55 -4
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup_mcp.py +95 -7
- {stringcup-3.23.0 → stringcup-3.24.0}/LICENSE +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/NOTICE +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/PYPI-README.md +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/setup.cfg +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.egg-info/SOURCES.txt +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.egg-info/dependency_links.txt +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.egg-info/entry_points.txt +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.egg-info/requires.txt +0 -0
- {stringcup-3.23.0 → stringcup-3.24.0}/stringcup.egg-info/top_level.txt +0 -0
|
@@ -22,9 +22,15 @@ build-backend = "setuptools.build_meta"
|
|
|
22
22
|
[project]
|
|
23
23
|
name = "stringcup"
|
|
24
24
|
description = "End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server"
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# long_description is
|
|
25
|
+
# THE DISTRIBUTION'S OWN LONG DESCRIPTION, not clients/python/README.md.
|
|
26
|
+
#
|
|
27
|
+
# long_description is FROZEN AT UPLOAD and immutable for the life of a version.
|
|
28
|
+
# The client README could not serve here: it is simultaneously relay-served at
|
|
29
|
+
# /clients/README.md -- so a `pip install stringcup` line in it would publish an
|
|
30
|
+
# instruction that 404s before the package exists -- and a target of
|
|
31
|
+
# apply-published-docs.py, so it MOVES after publication while the uploaded copy
|
|
32
|
+
# cannot. This file is only ever read from PyPI, where the package exists by
|
|
33
|
+
# construction, so it can lead with pip today.
|
|
28
34
|
readme = "PYPI-README.md"
|
|
29
35
|
license = {text = "Apache-2.0"}
|
|
30
36
|
requires-python = ">=3.7"
|
|
@@ -75,10 +75,10 @@ except ImportError as _exc: # pragma: no cover
|
|
|
75
75
|
"On Python 3.7 pin it below 46 (see requirements.txt) — 46 drops 3.7."
|
|
76
76
|
) from _exc
|
|
77
77
|
|
|
78
|
-
__version__ = "3.
|
|
78
|
+
__version__ = "3.24.0"
|
|
79
79
|
|
|
80
80
|
#: Numeric form, for comparisons. Compare this, never `__version__`.
|
|
81
|
-
version_info = (3,
|
|
81
|
+
version_info = (3, 24, 0)
|
|
82
82
|
|
|
83
83
|
#: Version of the PyPI DISTRIBUTION, which ships this module and
|
|
84
84
|
#: `stringcup_mcp.py` together. **This is a third number and it is not
|
|
@@ -109,7 +109,7 @@ version_info = (3, 22, 0)
|
|
|
109
109
|
#: It must increase whenever either module's version does.
|
|
110
110
|
#: `clients/python/test_contract.py` snapshots all three and fails on any
|
|
111
111
|
#: change, so bumping a module forces a decision about this one.
|
|
112
|
-
__dist_version__ = "3.
|
|
112
|
+
__dist_version__ = "3.24.0"
|
|
113
113
|
|
|
114
114
|
__all__ = [
|
|
115
115
|
"Client",
|
|
@@ -241,6 +241,8 @@ FEATURES = {
|
|
|
241
241
|
"local_channel_labels": (3, 21, 0), # label_for(), stored client-side only
|
|
242
242
|
# 3.22.0
|
|
243
243
|
"label_addressing": (3, 22, 0), # a label works wherever an id does
|
|
244
|
+
"identity_source": (3, 24, 0), # load_or_register says which it did
|
|
245
|
+
"self_join_refused": (3, 24, 0), # join_rendezvous detects a shared identity
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
DEFAULT_BASE_URL = "https://stringcup.com/api/v2"
|
|
@@ -1580,6 +1582,9 @@ class Client:
|
|
|
1580
1582
|
if isinstance(trust_store, str):
|
|
1581
1583
|
trust_store = TrustStore(trust_store)
|
|
1582
1584
|
self.trust_store = trust_store
|
|
1585
|
+
#: "registered" if this identity was created by `load_or_register`,
|
|
1586
|
+
#: "loaded" if it came off disk, None when constructed directly.
|
|
1587
|
+
self.identity_source: Optional[str] = None
|
|
1583
1588
|
|
|
1584
1589
|
# Optional append-only JSONL record of every message in and out.
|
|
1585
1590
|
# The relay deletes a message once it is acknowledged, so without this
|
|
@@ -1710,11 +1715,21 @@ class Client:
|
|
|
1710
1715
|
|
|
1711
1716
|
kwargs["transcript"] = transcript
|
|
1712
1717
|
|
|
1718
|
+
# WHICH OF THE TWO HAPPENED IS REPORTABLE, and that is not cosmetic.
|
|
1719
|
+
# Two sessions on one machine pointed at one identity file both get the
|
|
1720
|
+
# same identity: the first registers, the second LOADS -- and with
|
|
1721
|
+
# nothing distinguishing them, both narrate "identity registered" and
|
|
1722
|
+
# the collision is invisible in the only report anyone reads. Observed
|
|
1723
|
+
# on a real two-session install, where the visible symptom was a
|
|
1724
|
+
# pairing that never completed.
|
|
1713
1725
|
if os.path.exists(path):
|
|
1714
|
-
|
|
1726
|
+
client = cls(Identity.load(path), base_url=base_url, **kwargs)
|
|
1727
|
+
client.identity_source = "loaded"
|
|
1728
|
+
return client
|
|
1715
1729
|
|
|
1716
1730
|
client = cls.register(base_url=base_url, display_name=display_name, **kwargs)
|
|
1717
1731
|
client.identity.save(path)
|
|
1732
|
+
client.identity_source = "registered"
|
|
1718
1733
|
return client
|
|
1719
1734
|
|
|
1720
1735
|
def update_identity(
|
|
@@ -2276,6 +2291,42 @@ class Client:
|
|
|
2276
2291
|
detected. With it, a mismatch raises `VerificationFailed`.
|
|
2277
2292
|
"""
|
|
2278
2293
|
info = self.rendezvous(token=token, wait=0)
|
|
2294
|
+
|
|
2295
|
+
# SELF-JOIN: you opened this rendezvous yourself, which means two
|
|
2296
|
+
# sessions are sharing one identity file.
|
|
2297
|
+
#
|
|
2298
|
+
# The relay hands back the role this identity ALREADY HOLDS, because
|
|
2299
|
+
# that is what lets a restart resume. So an identity that opened this
|
|
2300
|
+
# rendezvous and then tries to JOIN it is told "you are the initiator"
|
|
2301
|
+
# and waits for a responder that cannot arrive -- an infinite polite
|
|
2302
|
+
# retry, reported as a peer that never started. Observed in the field
|
|
2303
|
+
# on a documented install.
|
|
2304
|
+
#
|
|
2305
|
+
# THE RELAY CANNOT DETECT THIS AND THIS CLIENT CAN, which is why the
|
|
2306
|
+
# check is here. An initiator legitimately re-polling with its own
|
|
2307
|
+
# token is byte-identical on the wire to a self-join: same identity,
|
|
2308
|
+
# holds initiator, token supplied. The difference is INTENT, and only
|
|
2309
|
+
# the caller knows it -- `join_rendezvous` was called, so a reported
|
|
2310
|
+
# role of initiator is provably wrong. Supplying the intent to the
|
|
2311
|
+
# relay instead would mean a client naming its own role, which is the
|
|
2312
|
+
# thing that caused the original double-rendezvous deadlock.
|
|
2313
|
+
#
|
|
2314
|
+
# Terminal, not retryable: retrying cannot conjure a second identity,
|
|
2315
|
+
# and "call again" is exactly the advice that produced the infinite
|
|
2316
|
+
# wait.
|
|
2317
|
+
if info.get("role") == PAIRING_ROLES[0]:
|
|
2318
|
+
raise StringcupError(
|
|
2319
|
+
"You already hold the initiator side of this rendezvous, so "
|
|
2320
|
+
"you are trying to pair with yourself. Two sessions are "
|
|
2321
|
+
"almost certainly sharing one identity file: this identity "
|
|
2322
|
+
"(%s) opened the rendezvous you are joining. Check "
|
|
2323
|
+
"STRINGCUP_IDENTITY -- if it is set in a user-scope MCP "
|
|
2324
|
+
"config, every session on the machine shares one identity. "
|
|
2325
|
+
"Give each agent its own (STRINGCUP_IDENTITY_NAME=<name>), or "
|
|
2326
|
+
"unset it and let the per-directory default apply. Do not "
|
|
2327
|
+
"retry: there is no second party to wait for." % self.id
|
|
2328
|
+
)
|
|
2329
|
+
|
|
2279
2330
|
if info.get("peer_id"):
|
|
2280
2331
|
if secret:
|
|
2281
2332
|
return self._verify_pairing(
|
|
@@ -10,14 +10,20 @@ server placed next to the relay would hold both agents' keys and destroy the
|
|
|
10
10
|
end-to-end property that is the entire point of Stringcup. There is deliberately
|
|
11
11
|
no remote/HTTP transport here.
|
|
12
12
|
|
|
13
|
-
Configure (Claude Code, Claude Desktop, or any MCP host)
|
|
13
|
+
Configure (Claude Code, Claude Desktop, or any MCP host). With `uvx` there is
|
|
14
|
+
nothing to download and no path to get right:
|
|
14
15
|
|
|
15
16
|
{"mcpServers": {"stringcup": {
|
|
16
17
|
"command": "uvx",
|
|
17
|
-
"args": ["--
|
|
18
|
-
"/path/to/stringcup_mcp.py"]}}}
|
|
18
|
+
"args": ["--from", "stringcup", "stringcup-mcp"]}}}
|
|
19
19
|
|
|
20
|
-
Or
|
|
20
|
+
Or `pip install stringcup`, which provides a `stringcup-mcp` console script:
|
|
21
|
+
|
|
22
|
+
{"mcpServers": {"stringcup": {"command": "stringcup-mcp"}}}
|
|
23
|
+
|
|
24
|
+
Only if you are running this file straight from a `curl` and not installing --
|
|
25
|
+
this is the one variant that names a versioned file by path, and so the one
|
|
26
|
+
that breaks when it moves:
|
|
21
27
|
|
|
22
28
|
{"mcpServers": {"stringcup": {
|
|
23
29
|
"command": "python3", "args": ["/path/to/stringcup_mcp.py"]}}}
|
|
@@ -25,6 +31,11 @@ Or, with `cryptography` already installed:
|
|
|
25
31
|
Environment:
|
|
26
32
|
|
|
27
33
|
STRINGCUP_IDENTITY identity file path (default ~/.stringcup/identity.json)
|
|
34
|
+
STRINGCUP_IDENTITY_NAME a NAME, resolved beside the default identity, for
|
|
35
|
+
running more than one agent on one machine. The
|
|
36
|
+
default is one identity per USER, not per session, so
|
|
37
|
+
two sessions sharing it are the same agent and cannot
|
|
38
|
+
pair with each other
|
|
28
39
|
STRINGCUP_BASE_URL relay base URL (default https://stringcup.com/api/v2)
|
|
29
40
|
STRINGCUP_TRUST_STORE pinned peer fingerprints (default alongside identity)
|
|
30
41
|
STRINGCUP_TRANSCRIPT JSONL log of every message in and out. ON BY DEFAULT:
|
|
@@ -48,6 +59,7 @@ from __future__ import annotations
|
|
|
48
59
|
|
|
49
60
|
import json
|
|
50
61
|
import binascii
|
|
62
|
+
import hashlib
|
|
51
63
|
import os
|
|
52
64
|
import sys
|
|
53
65
|
import time
|
|
@@ -78,7 +90,7 @@ stringcup.require_features("short_timeouts", "sent_seq", "inbox_quota_errors",
|
|
|
78
90
|
"verified_pairing_pins", "local_pairing_role",
|
|
79
91
|
"header_framed_verify", "undecryptable_visible", "structural_pin_rollback")
|
|
80
92
|
|
|
81
|
-
__version__ = "1.
|
|
93
|
+
__version__ = "1.19.0"
|
|
82
94
|
|
|
83
95
|
#: The MCP revision this server implements.
|
|
84
96
|
PROTOCOL_VERSION = "2025-06-18"
|
|
@@ -113,7 +125,7 @@ DEFAULT_IDENTITY = os.path.expanduser("~/.stringcup/identity.json")
|
|
|
113
125
|
#:
|
|
114
126
|
#: A newer library is NOT an error: it is usually fine and blocking it would
|
|
115
127
|
#: break legitimate installs. It is reported, not refused.
|
|
116
|
-
BUILT_AGAINST = (3,
|
|
128
|
+
BUILT_AGAINST = (3, 24, 0)
|
|
117
129
|
|
|
118
130
|
|
|
119
131
|
def _version_note() -> Optional[str]:
|
|
@@ -197,7 +209,76 @@ _TRANSCRIPT: Optional[str] = None
|
|
|
197
209
|
|
|
198
210
|
|
|
199
211
|
def _identity_path() -> str:
|
|
200
|
-
|
|
212
|
+
"""
|
|
213
|
+
Where this agent's identity lives.
|
|
214
|
+
|
|
215
|
+
TWO AGENTS ON ONE MACHINE MUST BE ABLE TO TALK TO EACH OTHER, and for one
|
|
216
|
+
release they could not. The default was one identity file per *user*, so
|
|
217
|
+
two sessions both loaded it, became the same identity, and the symptom was
|
|
218
|
+
not an error: the second rejoins the first's own rendezvous, is handed back
|
|
219
|
+
the role it already holds, and waits for a counterpart that cannot arrive.
|
|
220
|
+
Reported from a live two-session install where both agents printed the same
|
|
221
|
+
id and both said "identity registered".
|
|
222
|
+
|
|
223
|
+
Resolution order, and every step exists for a reason:
|
|
224
|
+
|
|
225
|
+
1. `STRINGCUP_IDENTITY` -- an explicit path always wins. **Do not put this
|
|
226
|
+
in a USER-scope MCP config**: that is precisely what makes every session
|
|
227
|
+
on the machine share one identity, and it is how the collision was
|
|
228
|
+
found. Per-project config, or nothing at all, is correct.
|
|
229
|
+
2. `STRINGCUP_IDENTITY_NAME` -- a name, not a path, resolved beside the
|
|
230
|
+
default. Short enough for a one-liner, stable across restarts.
|
|
231
|
+
3. An existing `~/.stringcup/identity.json` -- **never break an installed
|
|
232
|
+
agent.** If the legacy single-file default is already there it keeps
|
|
233
|
+
being used, because silently resolving somewhere else would mint a new
|
|
234
|
+
identity and make that agent unreachable at the id its peers hold. That
|
|
235
|
+
is the worst failure this project has, so it is not risked for tidiness.
|
|
236
|
+
4. Otherwise, per working directory: `agents/<dir>-<hash>.json`.
|
|
237
|
+
|
|
238
|
+
Step 4 is the one that makes the default safe, and it is a narrow use of
|
|
239
|
+
cwd. A cwd-*relative* file was rejected before and stays rejected -- it
|
|
240
|
+
breaks the moment you `cd`. This puts the file in the same private
|
|
241
|
+
directory as always and only uses cwd to NAME it, so an agent relaunched
|
|
242
|
+
in its own project gets its identity back while a different project gets
|
|
243
|
+
its own. The residual risk is renaming or moving a project directory, which
|
|
244
|
+
reads as a fresh identity; `whoami` reports `identity_source: registered`
|
|
245
|
+
and a new id when that happens, which is the signal an operator needs.
|
|
246
|
+
|
|
247
|
+
When cwd carries no useful scope -- `/` or the home directory itself --
|
|
248
|
+
step 4 would name every agent identically, so it falls back to the single
|
|
249
|
+
file rather than pretending to separate them.
|
|
250
|
+
"""
|
|
251
|
+
explicit = os.environ.get("STRINGCUP_IDENTITY")
|
|
252
|
+
if explicit:
|
|
253
|
+
return explicit
|
|
254
|
+
|
|
255
|
+
home = os.path.dirname(DEFAULT_IDENTITY)
|
|
256
|
+
name = (os.environ.get("STRINGCUP_IDENTITY_NAME") or "").strip()
|
|
257
|
+
if name:
|
|
258
|
+
allowed = ("abcdefghijklmnopqrstuvwxyz"
|
|
259
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
|
260
|
+
safe = "".join(c if c in allowed else "-" for c in name).strip(".-")
|
|
261
|
+
# NOT "identity": a name that sanitises to nothing would land on the
|
|
262
|
+
# legacy default and silently share the identity this separates.
|
|
263
|
+
return os.path.join(home, (safe or "unnamed") + ".json")
|
|
264
|
+
|
|
265
|
+
if os.path.exists(DEFAULT_IDENTITY):
|
|
266
|
+
return DEFAULT_IDENTITY
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
cwd = os.path.realpath(os.getcwd())
|
|
270
|
+
except OSError:
|
|
271
|
+
return DEFAULT_IDENTITY
|
|
272
|
+
|
|
273
|
+
if cwd in (os.sep, os.path.realpath(os.path.expanduser("~"))):
|
|
274
|
+
return DEFAULT_IDENTITY
|
|
275
|
+
|
|
276
|
+
allowed = ("abcdefghijklmnopqrstuvwxyz"
|
|
277
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
|
278
|
+
slug = "".join(c if c in allowed else "-"
|
|
279
|
+
for c in os.path.basename(cwd))[:32].strip(".-") or "agent"
|
|
280
|
+
digest = hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:8]
|
|
281
|
+
return os.path.join(home, "agents", "%s-%s.json" % (slug, digest))
|
|
201
282
|
|
|
202
283
|
|
|
203
284
|
#: Set STRINGCUP_TRANSCRIPT to this to turn the transcript off.
|
|
@@ -368,6 +449,13 @@ def tool_whoami(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
368
449
|
# reading source. It is on by default now, so most holders of one will
|
|
369
450
|
# not have chosen it. null means disabled.
|
|
370
451
|
"transcript_file": _TRANSCRIPT,
|
|
452
|
+
# "registered" means this call created the identity; "loaded" means it
|
|
453
|
+
# was already on disk. Load-bearing for the same reason identity_file
|
|
454
|
+
# is: two sessions pointed at one file both get the same identity, and
|
|
455
|
+
# without this an agent reports "identity registered" either way, so
|
|
456
|
+
# the collision never surfaces. If two agents on one machine report the
|
|
457
|
+
# same id, they ARE one agent and cannot pair with each other.
|
|
458
|
+
"identity_source": getattr(_client, "identity_source", None),
|
|
371
459
|
}
|
|
372
460
|
|
|
373
461
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|