Skip to content

v0.1.43

Choose a tag to compare

@memtomem memtomem released this 26 Jul 02:56
b8a991e

Upgrade notes

  • The # copy/paste hint unavailable: … diagnostic now reads a value contains characters that cannot be displayed safely rather than a 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 skip now renders a
    terminal-hostile character in the interpolated command escaped, so the
    snippet is copy-pasteable where it previously carried the raw value. The
    server key beside it is the constant memtomem-stm and never carried one.
    command now goes through json.dumps, which is ensure_ascii=True, so
    every non-ASCII character in it — CJK, emoji, accented paths — renders as
    a \uXXXX escape 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
    in mms eject's and mms prune's failure lists, where it previously wrapped
    across several. (#759)
  • mms add <name> now exits 1 with an invalid_name error — under --json
    too — for a name that is not valid UTF-8, where 0.1.42 aborted with an
    UnicodeEncodeError traceback 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 validate and
    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, b instead of the Python list repr none 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's command and env keys,
    mms project show's no-marker branches, and mms 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 \udxxx literal rather than failing the
    response that carries it. Clean responses are byte-identical. (#773)
  • stm_memory_propose's max_content_chars and the 512/256 limits on
    source_ref and idempotency_key now 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_large or
    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 to cli/_display.py by #768 later in this release, and
    re-exported from cli/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.loads with 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 the Note: sentence and the
    raw one, live ESC and all, in the mms eject command on the line below.
    The prose sites covered are: mms remove's eject Note: sentence, in
    both the server name and the imported-from label (an unrecognized
    origin.source.kind is 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 members json.dumps(…, ensure_ascii=False) escapes only the C0 ones and so left the rest raw
    inside string values; and the mms remove flow'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 # Edit line the server name is now rendered by json.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
    reads a value contains characters that cannot be displayed safely rather
    than a 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, so control characters would be false for the separators and the
    lone surrogates.
    mms remove --json embeds the eject hint in its warnings array, 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
    (the name key in the result and in --json failures) stay raw. Those are
    data, not display: of the set's members json.dumps escapes 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, which json.dumps also 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.dumps already produced intact, but makes the encoding non-injective
    (a name containing the literal text
    \u001B renders like one containing a real ESC). Within the # Edit line
    the escapes are mixed-case: a character json.dumps already escapes itself
    keeps its lowercase form, while one it leaves raw under ensure_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 --json payload is a different matter: json.dumps returns
    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 — the mms list and mms prune
    tables, the discovery/mms add flow, the eject summary and backup-log lines,
    and mms 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 --validate failure 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
    over tools/list; mms doctor's check labels and details; mms stats's
    per-tool compression table and mms 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, health and tune, whose text names the offending config key.
    Unchanged: every --json
    payload and every _json_fail envelope keep raw values — those are decoded
    by a consumer, not read off a terminal — as do the copy/paste hints, which
    _shell_join already 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 in mms host,
    mms project, mms import, mms config validate and mms hook, whose
    modules have no access to this helper.
    Three of these were more than cosmetic. mms health --names and 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 in mms tune such 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
    UnicodeEncodeError and print no table at all; the row now renders with the
    character escaped. (The --json half 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 by mms register --mcp skip
    and by mms init's skip option now renders command through json.dumps
    like the neighbouring args and env, 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 in mms eject's and mms 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's reason, and the env summary — escaped inside
    _format_env_summary, so mms host sync is covered by the same change, and
    after redaction on the default path — --show-imported remains an explicit
    opt-in that displays real values, escaped but not redacted);
    mms host's status and scan tables, every sync --plan bucket, the
    re-stamp diff's name and Source: lines, and the --apply confirmation
    prompt; mms project's six echo sites, its marker-backed show output, its
    tab-separated list, and eleven error messages;
    and mms config validate and mms 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. The mms host sync --apply prompt
    authorizes removing registry entries, and a CR in a name overwrites the
    rendered [y/N] the user is answering. And mms project enable / show NAME
    echo a name straight from argv, where POSIX surrogateescape decoding
    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 --json legs, which are
    pinned by tests.
    Not covered by this sweep, and closed further down this section: the
    re-stamp diff's command and env-key fields, the two mms project show
    fallback branches, and mms hook's preview and error messages.
    Behavior change: on these surfaces a value carrying a terminal-hostile
    character now renders as its \uXXXX escape 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 as none of a, b rather than the Python list
    repr none 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 the UnicodeEncodeError landed at the click.echo or file write
    that encoded it. mms remove <name> --yes --json deleted 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
    --json legs of add/remove/prune/eject including their error and
    confirmation_required envelopes, the read-only status/list/stats/
    health/doctor/tune payloads, 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=False now 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 plain json.loads with 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 with surrogateescape, 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-json hint is not a JSON document and needs none of this: its
    command form is refused wholesale by _shell_join and its # Edit form is
    display-escaped, both since #756. The argv mms eject actually 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, so mms import's registry could never hold one either.
    Behavior change: mms add <name> now exits 1 with an invalid_name
    error — under --json too — where it previously aborted with an
    UnicodeEncodeError traceback having written nothing. mms init's manual
    prompt refuses as well (its --json envelope reports the generic setup
    failure, not invalid_name), and the discovery scan behind
    mms add --from-clients and mms init skips such an entry, with a note on
    stderr, instead of importing it — so the other servers in that host config
    still import. list, remove, surfacing and the rest stay permissive by
    design, so a config that already holds such a name can be inspected and
    repaired: mms remove clears the entry and mms surfacing <name> off
    toggles it in either output mode, this change covering the --json reports
    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 doctor and mms health print 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 eject is checked the same way before it spawns either claude 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 --force path. (#758)

  • The daemon starts, and every daemon-touching CLI command runs, when
    MEMTOMEM_STM_HOOK_SURFACE_TOOLS holds 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 with surrogateescape, so a lone surrogate reached that
    encode with no config file involved. The fingerprint is frozen in
    DaemonServer.__init__, before serve() and outside any try, so the
    resulting UnicodeEncodeError meant the daemon process never started;
    mms daemon start, and mms daemon status/stop through 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_line ends 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 --json legs use, and read_message escapes what it decodes. The read
    half is load-bearing rather than belt-and-braces, because the write half's
    escape is the JSON one and json.loads decodes 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/list metadata
    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 resulting UnicodeEncodeError escaped both call sites — the reload site
    catches only (OSError, PolicyBundleError) and the startup apply is outside
    that try — and failed every tools/list and tools/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, in structuredContent and 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 \ud800 are 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 import skips a host entry it cannot store instead of aborting the whole
    import. A host config is plain json.loads with 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 via repr, but never a command,
    argument or environment value (those are routinely secrets and this text
    reaches CI logs) — the same refusal mms add and 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. sqlite3 encodes text parameters to UTF-8, so the failure landed at
    execute time, 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 \ud800 straight 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.
    ProxyCache also stored a response body without escaping it. sqlite3
    encodes text parameters to UTF-8, so that raised at execute; 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 hashes server and tool through errors="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. A server/tool that cannot be a SQLite text parameter
    now skips the store rather than being escaped into one, because an escaped
    name is unmatchable by clear() and aliases that same distinct identifier;
    clear() returns 0 for such a filter, which is what it now always matches,
    and stm_proxy_cache_clear escapes 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
    content and source_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-supplied idempotency_key skipped the hash but failed later and worse:
    all three go verbatim into the outbound mem_do params, whose SDK
    serialization refuses a surrogate, so it degraded to candidate_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_chars and the 512/256 limits on
    source_ref and idempotency_key now 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_large or
    idempotency_key_too_large. This is deliberate,
    and it is the only denomination that holds on both routes: with
    surfacing.use_daemon enabled 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 of server.py safe.
    (#778, fixes #777)

  • The display escaping now reaches the CLI surfaces the #760 sweep left. The
    mms host re-stamp diff escaped the server name and the Source: line but
    rendered command and the env keys raw in all three branches; those lines
    reach sync --plan and the --apply confirmation 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 rendered cwd, cwd.name and 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 with surrogateescape. And
    mms hook's preview rendered rendered_block raw, which embeds the
    source-checkout command; it is escaped at the render rather than on the
    field, since new_text is 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 with splitlines() would
    consume a hostile CR as a line break instead of showing it. All seven
    HookInstallError raises are covered by escaping at the two
    ClickException sites 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_imported and the
    post-apply paths in mms host, last_seen and two exception boundaries in
    mms project, and the shared _write_lock messages.
    Not changed: the args list 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)