Skip to content

mentra 0.20.0

Choose a tag to compare

@WendellXY WendellXY released this 24 Aug 08:42
· 258 commits to main since this release
Immutable release. Only release title and notes can be modified.

mentra 0.20.0. mentra-provider is unchanged since 0.6.0 and is not part of this release.

Breaking

  • SessionEvent::PermissionRequested gains a classification field, so a pattern without a trailing .., or a literal constructing the variant, stops compiling.
  • McpSseLimits gains max_tools, so an exhaustive struct literal needs ..Default::default().
  • ToolDefinition/ToolExecutor are now implemented for Box<T>. Because Box is #[fundamental], a downstream crate that wrote its own impl ToolDefinition for Box<ItsOwnTool> now collides — the hand-written forwarding these impls exist to replace.

Behavioral

  • SessionEvent::MemoryUpdated now actually fires; it had never been emitted by any released version. A failed memory ingest surfaces as a Notice with NoticeSeverity::Warning.

SessionEvent::MemoryUpdated is emitted at last

  • The variant had been public since April while nothing sent it. The bridge
    translating the runtime's MemoryIngestFinished into it was implemented and
    tested from the start but registered nowhere, because the only hook list in
    sight was the runtime's shared one — where a bridge holding one session's
    channel would forward every agent's memory activity to whichever session
    installed it.
  • The correct place existed all along: every session is already built on its
    own RuntimeHandle clone, and each with_* step rebuilds that clone's
    MemoryEngine from the hook list the clone carries. The bridge is now
    appended to that per-session list in create_session* and resume_session*,
    so a session's stream carries the memory events of exactly its own agents —
    the session's agent and the subagents it spawns — and no other session's.
  • Behavior change for hosts: streams that never carried these events now do.
    A successful ingest arrives as MemoryUpdated { agent_id, stored_records }
    (including stored_records: 0 for an ingest with nothing new to store), and
    a failed ingest as a Notice with Warning severity.

The other two MCP transports get the same pagination fixes

  • Review found the tools/list page-cap off-by-one and the unbounded tool
    accumulation in the new Streamable HTTP client and noted both were copied
    from the older code. They were: the stdio and legacy HTTP+SSE clients carried
    the identical two bugs, so fixing only the newest transport would have left
    the two that ship today wrong.
  • All three now check the page cap only when another page is actually asked
    for, so a list exactly max_tool_pages long is accepted rather than refused
    for reaching the limit it may reach — and a cap of 1 no longer rejects
    every server alive. All three bound the total number of advertised tools
    (max_tools, defaulting to 4096), which the page and per-page byte caps
    never did.
  • Breaking: McpSseLimits gains a max_tools field, so an exhaustive
    struct literal needs ..Default::default().

A permission request names the lane a call will actually run in

  • ToolClassification::execution_category reported the category a tool's
    descriptor declares, while the scheduler asks the tool with the call's
    input and then coerces a terminal tool out of a parallel lane. Every preview
    builder in the tree copied the static value, so the two could disagree — and
    they already did on a builtin: files declares an exclusive mutation and
    reports a parallel read for a batch that only reads.
  • In-tree that direction was conservative. The mirror shape is not, and it is
    the one a host will write: a tool declaring ReadOnlyParallel while
    returning a mutating category from execution_category(input) was reported
    to the host as read-only-parallel while the runtime scheduled a mutation, so
    a host auto-approving ReadOnlyParallel auto-approved it.
  • The authorization path now reads the scheduler's own answer rather than the
    descriptor's, which fixes every tool at once instead of the preview builders
    that remember to. Found by adversarial review of the field as it landed.

The Streamable HTTP transport survives the servers it will meet

Adversarial review of the transport as it landed turned up ten defects. The
two that mattered most were both reachable by an ordinary misbehaving server,
and both traced to the same gap: the test fixture could not produce a server
that stalls, so neither could be written as a test.

  • A notification's response body was drained outside the deadline wrapping its
    POST. send() resolves on the response head and the body streams lazily, so
    a server that promised a body and then went quiet left that drain waiting
    forever — inside connect, which RuntimeBuilder::build_async calls
    serially with no deadline above it. One such server hung the whole runtime
    build rather than degrading its own connection.
  • A tools/call whose reply was lost on the application/json path was
    reported as a definite failure. Six exits between an accepted POST and a
    parsed reply bypassed the indeterminacy classification the event-stream path
    applied to all of them, so the same physical event got opposite answers
    depending only on which framing the server chose — and the stalled-read case
    was not merely reachable but unavoidable, since stream_idle_timeout always
    fires before call_tool_timeout. Since McpBridgedTool flattens the error
    to its Display, and only RequestIndeterminate says "must not be retried
    automatically", a model would retry a call that may have charged a card.
    Both paths now classify in one place, exempting a JSON-RPC error, which is
    the answer.
  • A server that forgot a session bricked itself permanently: the client
    reported SessionExpired and stopped, no caller reconnected, and every tool
    from that server stayed broken for the runtime's life. The specification
    requires starting a new session, so the client now re-handshakes and re-sends
    once. That is safe even for a tools/call, because a 404 for an unknown
    session is a refusal before dispatch. The replacement handshake runs through
    a non-recovering path, so a recovery cannot start another recovery.
  • The negotiated protocol revision is now checked against the ones this client
    implements instead of being echoed back unread. A consequence worth having:
    the header carries one of our own &'static constants, so no
    server-controlled bytes reach a request header on that path at all.
  • Smaller: the tools/list page cap rejected a list exactly max_tool_pages
    long (and max_tool_pages: 1 rejected every server); nothing bounded the
    list those pages accumulate into, so a server could push gigabytes through
    the walk and be stopped by the allocator rather than a limit (max_tools
    now bounds it); a session id is echoed on every request and so is now marked
    sensitive and held to the specification's visible-ASCII rule; a handshake
    failing after initialize orphaned the server-side session, and shutdown
    left a dead id in place; and a refused redirect is no longer treated as proof
    a call did not run, since a 303 answering a POST means the server processed
    it.

The fixture gained what it was missing — stalling, pagination, session expiry
and rotation, and per-method failures — and the suite went from 18 tests to
52. The two most important fixes were mutation-tested: reverting either makes
its test fail rather than pass quietly.

CI checks the code it was not checking

  • Every CI job ran without --all-features, so the openai-oauth feature was
    never compiled, linted, or tested there — 831 lines of credential-store code,
    which is precisely the kind that should not ship on the strength of a local
    run. Clippy, the tests, and the MSRV check now all pass --all-features; the
    MSRV floor still holds with them enabled.
  • Nothing built the documentation, so a broken intra-doc link was invisible to
    the whole pipeline: it is neither a compile error nor a lint. A docs job now
    builds the workspace with RUSTDOCFLAGS=-D warnings, which is what would have
    caught the seven links repaired just above — including one that named a field
    on the wrong type.
  • Nothing built the crate with its default features off either, though
    responses-websocket is default-on specifically so a host that only streams
    over HTTP+SSE can drop tokio-tungstenite from its tree. That configuration
    could have stopped compiling without failing anything; a job now checks it.

An MCP server can be reached over Streamable HTTP

  • Streamable HTTP replaced HTTP+SSE in protocol revision 2025-03-26, and
    current MCP servers are commonly streamable-only: they answer 404 on
    /sse because they never implemented the older transport, and 406 on
    /mcp because nothing in mentra sent an Accept header offering both reply
    types. Such a server was reachable through neither existing transport, which
    made an MCP integration impossible rather than degraded.
  • McpStreamableHttpServerConfig and McpStreamableHttpClient post every
    JSON-RPC message to the one configured endpoint and read each reply from that
    same response, whether the server answers with a single application/json
    body or opens a text/event-stream in it. RuntimeBuilder gains
    with_mcp_streamable_http_server and with_mcp_streamable_http_servers, and
    McpManager gains connect_streamable_http; the tools bridge exactly as the
    other two transports' do.
  • The session initialize assigns rides on every later request as
    Mcp-Session-Id, and shutdown ends it. A server is free to use no session
    at all, in which case none is invented and no header is sent.
  • The transport buys a structural simplification worth naming: because a reply
    arrives on the response to the request that asked for it, this client needs
    no background reader task, no pending-request map, and no window in which a
    response can arrive before its own request future resolves — all of which the
    legacy transport's single shared stream forces on its client. The reply's id
    is still checked, because a reply carrying a different id answers a different
    question. On the streaming path a mismatched id is skipped rather than
    returned, so a server cannot hand a caller another call's result by putting
    it on the wire first.
  • Two values arrive from the server and are then sent back on every request, so
    both are bounded and validated: a session id must be a usable header value
    within a length cap, and a reported protocol version that cannot be a header
    falls back to the revision this client requested rather than being forwarded.
    A newline in either would otherwise be request splitting.
  • Nothing is retried and nothing is replayed. A tools/call whose request may
    have reached the server but whose reply never arrived reports
    McpStreamableHttpError::RequestIndeterminate, so a caller can tell "may
    have run" from "definitely did not". A 4xx is exempt — those are rejections
    the server makes before dispatching the message — where a 5xx is not,
    because a server can fail after running the tool.

A permission request says what the call would do

  • ToolAuthorizer::authorize saw the full classification mentra had already
    computed — side-effect level, capabilities, durability, execution category,
    approval category — and none of it survived to the session layer.
    SessionEvent::PermissionRequested carried a preview field whose doc
    comment called it "JSON-encoded preview data" while it was only the tool's
    raw input JSON. So the one place a host is meant to write approval policy was
    exactly the place the classification was thrown away, leaving a host to
    re-derive it by matching tool names and parsing the input by hand.
  • PermissionRequested now carries classification: Option<ToolClassification>,
    a new type that is the authorization preview with the call's input left off.
    "Allow edits, refuse the network" is ToolSideEffectLevel::LocalState
    against ToolSideEffectLevel::External, readable from that field alone.
  • The field is Option rather than a defaulted ToolClassification because an
    absent classification is unknown, not harmless: a default would let an event
    replayed from a stream recorded before this existed read as a call that
    touches nothing. It carries #[serde(default)] so such a stream still
    deserializes, and every request a live session emits carries Some.
  • ToolQueued.mutability stays hardcoded to Unknown, now with a comment
    saying why rather than looking unfinished. Its three values cannot separate a
    local write from a process launch from a network call, which is the whole
    distinction this change is about; a host wanting that reads classification.
  • Breaking: SessionEvent is not #[non_exhaustive], so a downstream
    PermissionRequested { .. } pattern without a trailing .., or a literal
    constructing the variant, stops compiling.
  • The classification describes the call as it stood when the approver was
    asked. A pre-execution hook returning Modify runs afterwards and can
    replace the input, so for an input-dependent classification the executed call
    may classify differently — a host defeating its own policy, but previously
    undocumented.

A tool can be registered through a pointer

  • RuntimeBuilder::with_tool takes its tool by value, and neither of
    ExecutableTool's sub-traits had a forwarding impl for Box or Arc — so a
    host holding a tool behind a pointer, or wanting two runtimes to share one
    instance, had no public path in. ToolAuthorizer had had exactly this
    treatment for some time; the tool traits had not.
  • ToolDefinition and ToolExecutor are now implemented for Box<T> and
    Arc<T> where T: ?Sized, so Box<dyn ExecutableTool> and a shared Arc
    both register directly.
  • All eight methods forward explicitly, including every defaulted one. A method
    that fell through to its trait default instead of the inner tool would be a
    silent misrepresentation, and for authorization_preview it would present a
    host's tool to the approver as something other than what it is — so a test
    wraps a tool whose defaulted methods all return non-default values and
    asserts each one observed through the pointer is the inner tool's.
  • Breaking, narrowly: Box is #[fundamental], so a downstream crate
    could previously write impl ToolDefinition for Box<ItsOwnTool> and now
    collides with mentra's blanket impl. There is no Arc equivalent, since the
    orphan rules never permitted one. The pattern this breaks is precisely the
    hand-written forwarding the change exists to replace.
  • Both trait definitions now carry the obligation in their own docs: adding a
    method means adding it to tool::forwarding, because a new defaulted method
    would compile and pass the suite while answering for the pointer instead of
    the tool inside it.

A stdio MCP config cannot print its credentials

  • McpSseServerConfig wrapped its header values so a Debug could not print
    them; McpServerConfig did not — and stdio servers are where MCP credentials
    actually live, since the de-facto .mcp.json format puts tokens in env.
    Anything that formatted a config for a log, an error, or a panic printed the
    token, which a consumer could avoid for its own configs but not for one that
    reached a #[derive(Debug)] struct holding it.
  • McpServerConfig now has a hand-written Debug that keeps the variable
    names and replaces every value with [redacted]. The names are the half an
    operator needs — "it has GITHUB_TOKEN set" is the useful part — and keeping
    the field's type means this breaks nothing. Serialize is untouched, so a
    config written back to disk still carries what it was given.
  • SecretString moved to mcp::secret, shared by both HTTP transports rather
    than owned by the SSE one, and deliberately does not implement Serialize:
    adding #[derive(Serialize)] to a struct holding one is a compile error
    instead of a silent leak into a config dump or a persistence layer.