v0.1.43
Upgrade notes
- The
# copy/paste hint unavailable: …diagnostic now readsa value contains characters that cannot be displayed safelyrather thana value contains line-break or NUL characters, and stands in for a command whose values carry
any terminal-hostile character, not only CR/LF/NUL. (#756) - The JSON snippet printed by
mms register --mcp skipnow renders a
terminal-hostile character in the interpolatedcommandescaped, so the
snippet is copy-pasteable where it previously carried the raw value. The
server key beside it is the constantmemtomem-stmand never carried one.
commandnow goes throughjson.dumps, which isensure_ascii=True, so
every non-ASCII character in it — CJK, emoji, accented paths — renders as
a\uXXXXescape where 0.1.42 emitted it literally. The snippet still parses
to the same string. (#759) - A multi-line message from a host client's CLI now renders as one escaped line
inmms eject's andmms prune's failure lists, where it previously wrapped
across several. (#759) mms add <name>now exits 1 with aninvalid_nameerror — under--json
too — for a name that is not valid UTF-8, where 0.1.42 aborted with an
UnicodeEncodeErrortraceback having written nothing. The refusal is about
encodability, not display: it covers a lone surrogate, not every
terminal-hostile character. The same refusal applies to the other commands
that create an upstream server. (#758)- Across
mms import,mms host,mms project,mms config validateand
mms hook, a displayed value carrying a terminal-hostile character now
renders escaped rather than raw, and the aligned tables pad by the displayed
width. Values with nothing to escape render byte-identically, except
mms project disable's no-op line, which now lists MCP names as
none of a, binstead of the Python list reprnone of ['a', 'b'].
Coverage is the sites listed in the entry below, not these commands
wholesale; the rest is closed further down this section by #785, which
extends the same escaping to the re-stamp diff'scommandand env keys,
mms project show's no-marker branches, andmms hook's preview and errors.
(#769, #770, #771, #772, #785) - Proxied content is now modified in one case: a lone surrogate in an upstream
response is delivered as its escaped\udxxxliteral rather than failing the
response that carries it. Clean responses are byte-identical. (#773) stm_memory_propose'smax_content_charsand the 512/256 limits on
source_refandidempotency_keynow measure the escaped form, so a value
packed with lone surrogates can be refused where its raw length fit, under
that field's own reason —content_too_large,source_ref_too_largeor
idempotency_key_too_large. Payloads with no surrogate are unaffected. (#778)
Fixed
-
Terminal-hostile characters in a config-derived server name, source label or
path no longer reach the terminal verbatim in a hint. The set covers what
cannot be rendered or can rewrite the rendered line — control characters
such as ESC, the line separators, and the bidirectional controls, among
others. Its single authoritative definition is the_disp_escapes
predicate — moved tocli/_display.pyby #768 later in this release, and
re-exported fromcli/proxy.py— whose docstring enumerates the members and
the reason each is in; read that rather than this sentence for the exact
membership. Preserved: everything else, so CJK, emoji, ZWJ sequences and
the plain LRM/RLM marks render as before, and a value with nothing to
escape renders byte-identically. Config is plain
json.loadswith no character validation on server names, so an imported or
hand-edited config could carry any of these.
Prose escapes such a character in place, as\uXXXX. A runnable command
cannot — an escaped token would paste as a different server name — so it is
refused wholesale instead, extending the existing CR/LF/NUL guard to the same
class. Both halves of a hint therefore now agree about the same character.
Previously both printed such a name raw — for anything outside CR/LF/NUL
neither half guarded it — and escaping the prose alone would have replaced
that with a worse state: an escaped name in theNote:sentence and the
raw one, live ESC and all, in themms ejectcommand on the line below.
The prose sites covered are:mms remove's ejectNote:sentence, in
both the server name and the imported-from label (an unrecognized
origin.source.kindis displayed as recorded); the
# Remove '<name>' from <source>.line, in both values; the
# Edit … and remove '<name>' under mcpServers.line; the
# Edit <path> … <name>: <payload>restore line, in the path, the name and
the payload JSON, since of the set's membersjson.dumps(…, ensure_ascii=False)escapes only the C0 ones and so left the rest raw
inside string values; and themms removeflow's own
server-not-found error, confirmation prompt and success line, which print the
same key in the same screenful. The prompt is the one site where this was
more than cosmetic — a CR in the name overwrote the rendered[y/N]the user
was answering.
In that same# Editline the server name is now rendered byjson.dumps
rather than wrapped in literal quotes, so the fragment parses back to the
exact key. A name containing"used to produce invalid JSON, and one
containing\used to break it either way: followed by a character JSON
does not define as an escape (back\slash) the fragment was outright
invalid, while followed by one it does define (back\bslash) it parsed
silently into a different key — a real backspace.
Behavior change: the# copy/paste hint unavailable: …diagnostic now
readsa value contains characters that cannot be displayed safelyrather
thana value contains line-break or NUL characters, and stands in for a
command whose values carry any character in the class above, not only
CR/LF/NUL. The wording names no Unicode category on purpose: the class spans
several, socontrol characterswould be false for the separators and the
lone surrogates.
mms remove --jsonembeds the eject hint in itswarningsarray, so for a
name carrying one of these characters that string now contains\uXXXX
escapes or the diagnostic; the array's shape is unchanged, and a name with
nothing to escape produces a byte-identical string. Machine-readable fields
(thenamekey in the result and in--jsonfailures) stay raw. Those are
data, not display: of the set's membersjson.dumpsescapes the C0 ones,
and the rest of the
set round-trips through a JSON parser unharmed even though it is emitted
literally. The exception is a lone surrogate, whichjson.dumpsalso emits
literally but which then cannot be encoded at all — see the residuals below.
This closes the display wart the #752 entry below documents as out of scope
for #751. Residuals: a backslash is never escaped by the display sanitizer,
which keeps Windows paths readable and leaves any backslash escape
json.dumpsalready produced intact, but makes the encoding non-injective
(a name containing the literal text
\u001Brenders like one containing a real ESC). Within the# Editline
the escapes are mixed-case: a characterjson.dumpsalready escapes itself
keeps its lowercase form, while one it leaves raw underensure_ascii=False
— every member of the set outside C0 — is then escaped uppercase by the
sanitizer, as the path always is. A lone
surrogate in a--jsonpayload is a different matter:json.dumpsreturns
it unescaped and writing that document to the terminal then raises, so the
command emits no JSON at all. That predates this change and is unaffected by
it; see #757. And the
remaining prose sites the sweep found — themms listandmms prune
tables, the discovery/mms addflow, the eject summary and backup-log lines,
andmms health --names/mms doctor— are deferred to #755.
(#756, fixes #754) -
The same display escaping now covers the rest of the terminal output that
carries a value nobody validated. Which values those are, by command:
mms list's table cells (name, prefix, transport, compression, origin,
command/URL);mms prune's and the import previews' candidate rows, whose
command/URL cell comes from another client's config; the discovery flow's
Configuring '<name>'header, probe results and import summary;mms add's
own name echoes and its--validatefailure text;mms eject's refusal
reason, plan, warning, secret-gate prompt and failure lines;mms health's
server lines and, under--names, the tool names the upstream advertised
overtools/list;mms doctor's check labels and details;mms stats's
per-tool compression table andmms tune's preview, selector and per-tool
confirmation prompt, which key on tool names recorded from the upstream at
call time;mms surfacing's name echoes;mms gateway status/explain;
the prefix-collision error; and the config-validation warning shared by
status,healthandtune, whose text names the offending config key.
Unchanged: every--json
payload and every_json_failenvelope keep raw values — those are decoded
by a consumer, not read off a terminal — as do the copy/paste hints, which
_shell_joinalready refuses wholesale for this character class. Also
untouched, and not part of this change: values passed as argv by the user
running the command, and the same class of site inmms host,
mms project,mms import,mms config validateandmms hook, whose
modules have no access to this helper.
Three of these were more than cosmetic.mms health --namesand the two
metrics-derived tables are the first sites where the value is chosen by a
remote party rather than by whoever wrote the config: a proxied server
picks the tool names it advertises, so reaching this output needed no
access to any file on the machine — and inmms tunesuch a name renders
inside the prompt whose answer authorizes a config write. And in
mms list, a name containing a lone surrogate —"\ud800"is a legal JSON
escape, so such a config loads fine — made the command exit 1 with a bare
UnicodeEncodeErrorand print no table at all; the row now renders with the
character escaped. (The--jsonhalf of that failure is #757, separate.)
Column widths are unchanged: an escaped value is longer than its raw form
and so overflows its cell, exactly as an over-long ordinary name already
did.mms prune's preview, the one table whose width is computed rather
than fixed, measures the escaped names so its second column stays aligned.
Names are still padded by character count, so a CJK or emoji name misaligns
the table by as much as it did before — that is unrelated and untouched.
Behavior change: the JSON snippet printed bymms register --mcp skip
and bymms init's skip option now renderscommandthroughjson.dumps
like the neighbouringargsandenv, instead of wrapping the value in
literal quotes. The snippet is meant to be pasted into a client's config
file, and for any path containing\or"— on Windows, every path — it
previously produced a document that would not parse back. A path needing no
escaping renders byte-identically.
Behavior change: a multi-line message from a host client's CLI now
renders as one escaped line inmms eject's andmms prune's failure
lists, where it previously wrapped across several.
(#759, fixes #755) -
The same display escaping now reaches the CLI surfaces the earlier sweeps
left, closing the sites #760 enumerates plus several they do not name. By
command:mms import's plan listing (candidate name, source label, command,
the conflict line'sreason, and the env summary — escaped inside
_format_env_summary, somms host syncis covered by the same change, and
after redaction on the default path —--show-importedremains an explicit
opt-in that displays real values, escaped but not redacted);
mms host'sstatusandscantables, everysync --planbucket, the
re-stamp diff's name andSource:lines, and the--applyconfirmation
prompt;mms project's six echo sites, its marker-backedshowoutput, its
tab-separatedlist, and eleven error messages;
andmms config validateandmms hook, covering each error, unknown key and
warning, the hook change path and the backup path on both apply branches.
Column-aligned surfaces pad by the displayed width, so an escaped value no
longer offsets the columns after it.
Two of these are more than cosmetic. Themms host sync --applyprompt
authorizes removing registry entries, and a CR in a name overwrites the
rendered[y/N]the user is answering. Andmms project enable/show NAME
echo a name straight from argv, where POSIXsurrogateescapedecoding
produces a lone surrogate with nothing hostile on disk at all — so the error
prose is the first place such a value renders.
Values this package writes itself stay raw, as do the--jsonlegs, which are
pinned by tests.
Not covered by this sweep, and closed further down this section: the
re-stamp diff'scommandand env-key fields, the twomms project show
fallback branches, andmms hook's preview and error messages.
Behavior change: on these surfaces a value carrying a terminal-hostile
character now renders as its\uXXXXescape rather than raw, and the
aligned tables pad by that displayed width. A value with nothing to escape
renders byte-identically, with one exception:mms project disable's no-op
line now lists the MCP names asnone of a, brather than the Python list
reprnone of ['a', 'b']. (#769, #770, #771, #772, issue #760) -
A lone surrogate in a config no longer crashes the CLI's JSON output after
the write it was reporting.json.dumps(..., ensure_ascii=False)— which
keeps CJK and emoji readable — leaves such a code unit raw in the string it
returns, so theUnicodeEncodeErrorlanded at theclick.echoor file write
that encoded it.mms remove <name> --yes --jsondeleted the entry, saved
the config, and then failed rendering the report: exit 1 and empty stdout
for an operation that had succeeded. Every JSON document the CLI emits — the
--jsonlegs ofadd/remove/prune/ejectincluding their error and
confirmation_requiredenvelopes, the read-onlystatus/list/stats/
health/doctor/tunepayloads,mms host,mms project route,
mms config validate, the lock-timeout envelope, and the PostToolUse hook's
reply to Claude/Codex/Cursor — and every JSON config writer that passed
ensure_ascii=Falsenow goes through a serializer that re-escapes those code
units as\udxxx. (The writers that omit it, and the TOML writers, were never
exposed.) Two things
could supply one: config is plainjson.loadswith no character validation
and"\ud800"is a legal JSON escape, so an imported or hand-edited config
could carry it as a server name; and on POSIX a command-line argument holding
a byte that is not valid UTF-8 is decoded withsurrogateescape, so
mms add $'s\xffv' ...alone produced a name the CLI then could not write.
Clean payloads render byte-identically, and a rewritten config decodes back to
the identical name rather than losing the entry.mms eject's manual
claude mcp add-jsonhint is not a JSON document and needs none of this: its
command form is refused wholesale by_shell_joinand its# Editform is
display-escaped, both since #756. The argvmms ejectactually spawns was
the real remaining exposure there — see the entry below. (#758, fixes #757) -
The commands that create an upstream server now refuse a name that is not
valid UTF-8, rather than storing one that fails later. Being writable is not
the same as being usable: the server name is the first component of the
response-cache key and part of the Toolgraph contract fingerprint, both of
which hash encoded bytes and raise on such a character, and TOML cannot
represent it at all, somms import's registry could never hold one either.
Behavior change:mms add <name>now exits 1 with aninvalid_name
error — under--jsontoo — where it previously aborted with an
UnicodeEncodeErrortraceback having written nothing.mms init's manual
prompt refuses as well (its--jsonenvelope reports the generic setup
failure, notinvalid_name), and the discovery scan behind
mms add --from-clientsandmms initskips such an entry, with a note on
stderr, instead of importing it — so the other servers in that host config
still import.list,remove,surfacingand the rest stay permissive by
design, so a config that already holds such a name can be inspected and
repaired:mms removeclears the entry andmms surfacing <name> off
toggles it in either output mode, this change covering the--jsonreports
and the display escape covering the printed lines. Making the config writable
is what newly exposed that second half — before it, these commands failed
inside the write, so nothing had changed yet; afterwards the write lands and
only the report can still fail. Every command that writes the config was
re-checked by running it against such a config and comparing the file before
and after, and none now mutates and then raises. The read-only renderings of
mms list,mms doctorandmms healthprint the name display-escaped
rather than raising, as of #759 — the two escapes meet there, and a test
pins all three text legs so a regression in either shows up as a crash.
mms ejectis checked the same way before it spawns eitherclaude mcp
verb: the name and payload go out as subprocess arguments, which encode to
UTF-8, so the command now reports "server name or payload is not valid
UTF-8" and leaves both sides intact where it previously raised — after the
destructive pre-remove, on the--forcepath. (#758) -
The daemon starts, and every daemon-touching CLI command runs, when
MEMTOMEM_STM_HOOK_SURFACE_TOOLSholds a byte that is not valid UTF-8. That
variable is folded verbatim into the daemon's config fingerprint, which is
hashed by encoding it — and on POSIX an environment variable carrying such a
byte is decoded withsurrogateescape, so a lone surrogate reached that
encode with no config file involved. The fingerprint is frozen in
DaemonServer.__init__, beforeserve()and outside anytry, so the
resultingUnicodeEncodeErrormeant the daemon process never started;
mms daemon start, andmms daemon status/stopthrough the client's
_live_handshake_candidate, exited on an uncaught traceback. The fingerprint
now escapes lone surrogates before hashing, the same treatment--json
payloads have had since #758. It stays a fingerprint: distinct env values
still produce distinct digests, so two configs cannot collapse onto one
handshake or lock file. A value with nothing to escape hashes byte-identically
to before, so no existing daemon is orphaned by this. (#765, issue #761) -
A lone surrogate in a hook↔daemon frame no longer fails the connection.
encode_lineends in an explicit.encode("utf-8"), so such a frame raised
and the peer saw the socket close with no response — a hook waiting on a
reply, not an error it could report. Both ends are now covered, not just the
write end: the frame is serialized through the same surrogate-escaping writer
the--jsonlegs use, andread_messageescapes what it decodes. The read
half is load-bearing rather than belt-and-braces, because the write half's
escape is the JSON one andjson.loadsdecodes it faithfully back into the
code unit — escaping only on the way out would have moved the failure into
the receiving process, at whichever encode it reached next. It also covers a
peer that never escaped it. A frame with nothing to escape is byte-identical
on the wire and decodes to an equal object, so this is invisible to every
existing exchange. (#766, issue #761) -
In Toolgraph bundle mode, an upstream tool whose
tools/listmetadata
carries a lone surrogate is now rejected as drifted instead of taking the
whole catalog down. The contract fingerprint encodes its canonical JSON, and
it is computed inside the bind loop over every tool of every connection, so
the resultingUnicodeEncodeErrorescaped both call sites — the reload site
catches only(OSError, PolicyBundleError)and the startup apply is outside
thattry— and failed everytools/listandtools/call, not just the one
tool's. The reachable shape is metadata drift, which is exactly what the
digest exists to detect: a bundle can only ever be published for clean
metadata, since Toolgraph's own encoder raises on a surrogate too, but it
keys on the tool name, so a tool crawled clean and later serving a surrogate
binds by name and reaches the digest. Its digest now cannot match one no
producer could have published, which is the fail-closed rejection this path
is meant to produce. Clean metadata hashes byte-identically to the producer,
so every existing bundle keeps binding exactly as before — the cross-repo
golden-fixture test pins that. (#767, issue #761) -
An upstream response carrying a lone surrogate is delivered with that
character escaped instead of being discarded. The MCP SDK decodes a legal
"\ud800"escape out of the upstream's wire JSON into a raw code unit, and
TextContent(...).model_dump_json()then refuses to serialize it — so an
otherwise-successful response was lost to a serialization error. Such a
character is undeliverable by any route, so escaping it is strictly better
than losing the response around it.
Behavior change: proxied content is now modified in this one case. A lone
surrogate is replaced by the six literal characters\ud800(its JSON escape
spelling) in tool response text, instructuredContentand in_meta.
Content with no lone surrogate — everything else, including all CJK, emoji
and astral characters — is returned byte-identically, so this is invisible to
every well-formed response.
The escape happens once at ingest rather than at each serialization site,
which is also what keeps the compression budgets honest: they measure the
length of a re-serialized payload, so escaping later would have made them
count a string six characters shorter per surrogate than the one actually
delivered. (#773, issue #761) -
The same escaping now covers replies from the LTM core, so a surrogate in a
surfaced memory or in a review candidate no longer fails the STM tool
response carrying it. Two entry points, and each is needed: the SDK decodes a
surrogate off Core's wire into the text we read, and Core also returns JSON
inside that text, where the six characters\ud800are a legal escape
that survives text-level escaping and then decodes into a fresh code unit
when that nested document is parsed. Clean replies are unchanged. (#774, issue #761) -
mms importskips a host entry it cannot store instead of aborting the whole
import. A host config is plainjson.loadswith no character validation, so
a legal"\ud800"escape reaches the registry writer as a code unit that the
drift hash cannot encode and TOML cannot represent. That raise was uncaught
and left nothing imported, so one malformed entry cost every clean entry
beside it. Such an entry is now reported and skipped per entry, naming the
offending field, and the server name viarepr, but never a command,
argument or environment value (those are routinely secrets and this text
reaches CI logs) — the same refusalmms addand the discovery
scan have made since #757/#758, now applied to the third create path. (#775, issue #761) -
The SQLite pending store is hardened against a lone surrogate reaching it in
both directions, and the response cache and progressive store on the write
path.sqlite3encodes text parameters to UTF-8, so the failure landed at
executetime, where the caller logs it and leaves the response alone — the
cost was a silently uncached response and a warning per call. A\ud800
escape sitting in a stored row also decodes straight back into the code unit
through a plain parse, failing at the next encode downstream rather than at
the read; the pending store scrubs on read for that reason. With the ingest
escaping above in place nothing should reach these in normal operation; they
are hardened because their failure mode was bad out of proportion to its
cause. The cache and progressive readers are closed further down this
section. (#776, issue #761) -
The response cache and progressive store are now surrogate-safe on read as
well as write, closing part of the gap #761 left when it hardened only the
pending store. Both escape on write and then parsed with a plain
json.loads, which decodes the six characters\ud800straight back into
the code unit, so the value came back unencodable and raised at the next
encode downstream rather than at the read. The progressive store's__meta__
is a JSON document nested inside a chunk, so the backing store's own scrub
never reached it.
ProxyCachealso stored a response body without escaping it.sqlite3
encodes text parameters to UTF-8, so that raised atexecute; the caller
catches it and leaves the response alone, so the cost was a silently uncached
response and a warning on every call rather than a lost one.
Its identifiers are handled differently from its content, on purpose. The
cache key now hashesserverandtoolthrougherrors="surrogatepass"
rather than the escaping helper, which is documented as non-injective and
would have let one identifier's row answer for the distinct identifier
spelled with those six literal characters. That closes the aliasing this
change would otherwise have introduced; it does not make the key injective
in general, and two older collision classes in the same derivation are
tracked in #784. Aserver/toolthat cannot be a SQLite text parameter
now skips the store rather than being escaped into one, because an escaped
name is unmatchable byclear()and aliases that same distinct identifier;
clear()returns 0 for such a filter, which is what it now always matches,
andstm_proxy_cache_clearescapes the filter it echoes back so the reply
itself stays serializable. Clean values hash, store, read and clear exactly
as before. (#782, fixes #781) -
A lone surrogate in
stm_memory_propose's own arguments no longer escapes as
a traceback. The tool derives its idempotency key by hashing the client's
contentandsource_ref, and that.encode()sits above the tool's
try, so an unencodable code unit in either raised out of the tool instead of
returning one of its structured{"ok": false, "reason": …}replies. A
client-suppliedidempotency_keyskipped the hash but failed later and worse:
all three go verbatim into the outboundmem_doparams, whose SDK
serialization refuses a surrogate, so it degraded tocandidate_submit_failed
— a reason naming Core for a request that never left. All three are now
escaped at entry, so an ordinary value carrying a surrogate is delivered
rather than refused: the character is escapable, and the surrounding limits
are about size.
Behavior change:max_content_charsand the 512/256 limits on
source_refandidempotency_keynow measure the escaped form. One code
unit becomes six characters, so a value packed with surrogates can now be
refused where the raw length fit — with that field's own reason,
content_too_large,source_ref_too_largeor
idempotency_key_too_large. This is deliberate,
and it is the only denomination that holds on both routes: with
surfacing.use_daemonenabled the daemon re-applies these same three limits
to what is actually sent, and its refusal arrives as an opaque
candidate_submit_failed. Measuring the raw form would have made a value
near the limit succeed or fail depending on the transport. Payloads with no
surrogate are unaffected — the escaping helper returns them unchanged and
every limit behaves exactly as before.
This is the request half of the tool whose response half #761 closed; it was
left out of that issue deliberately, because a tool argument is not one of the
ingest points whose escaping made the rest ofserver.pysafe.
(#778, fixes #777) -
The display escaping now reaches the CLI surfaces the #760 sweep left. The
mms hostre-stamp diff escaped the server name and theSource:line but
renderedcommandand the env keys raw in all three branches; those lines
reachsync --planand the--applyconfirmation prompt that authorizes
removing registry entries, so a CR there overwrites the[y/N]the user is
answering. The env keys are escaped inside_format_env_keys_redacted, their
only renderer, and still after redaction, which alone decides what is shown.
mms project show's two no-marker branches renderedcwd,cwd.nameand the
git root raw — a path needs no hostile config to carry a lone surrogate, since
a POSIX byte that is not valid UTF-8 decodes withsurrogateescape. And
mms hook's preview renderedrendered_blockraw, which embeds the
source-checkout command; it is escaped at the render rather than on the
field, sincenew_textis what gets written. The block is split on its
structural newline before escaping — escaping first would collapse a
fifteen-line preview into one line, and splitting withsplitlines()would
consume a hostile CR as a line break instead of showing it. All seven
HookInstallErrorraises are covered by escaping at the two
ClickExceptionsites instead, so messages added later are too.
Not covered, and tracked in #786: several more terminal renders of
unvalidated values in these same modules —last_importedand the
post-apply paths inmms host,last_seenand two exception boundaries in
mms project, and the shared_write_lockmessages.
Not changed: theargslist in that same diff line.list.__repr__already
escapes every character this class covers — verified over all 1,114,112 code
points — so routing it through the escaper too would only double-escape.
(#785, fixes #780)