Repository navigation
mentra 0.20.0
·
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::PermissionRequestedgains aclassificationfield, so a pattern without a trailing.., or a literal constructing the variant, stops compiling.McpSseLimitsgainsmax_tools, so an exhaustive struct literal needs..Default::default().ToolDefinition/ToolExecutorare now implemented forBox<T>. BecauseBoxis#[fundamental], a downstream crate that wrote its ownimpl ToolDefinition for Box<ItsOwnTool>now collides — the hand-written forwarding these impls exist to replace.
Behavioral
SessionEvent::MemoryUpdatednow actually fires; it had never been emitted by any released version. A failed memory ingest surfaces as aNoticewithNoticeSeverity::Warning.
SessionEvent::MemoryUpdated is emitted at last
- The variant had been public since April while nothing sent it. The bridge
translating the runtime'sMemoryIngestFinishedinto 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
ownRuntimeHandleclone, and eachwith_*step rebuilds that clone's
MemoryEnginefrom the hook list the clone carries. The bridge is now
appended to that per-session list increate_session*andresume_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 asMemoryUpdated { agent_id, stored_records }
(includingstored_records: 0for an ingest with nothing new to store), and
a failed ingest as aNoticewithWarningseverity.
The other two MCP transports get the same pagination fixes
- Review found the
tools/listpage-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 exactlymax_tool_pageslong is accepted rather than refused
for reaching the limit it may reach — and a cap of1no 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:
McpSseLimitsgains amax_toolsfield, so an exhaustive
struct literal needs..Default::default().
A permission request names the lane a call will actually run in
ToolClassification::execution_categoryreported 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:filesdeclares 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 declaringReadOnlyParallelwhile
returning a mutating category fromexecution_category(input)was reported
to the host as read-only-parallel while the runtime scheduled a mutation, so
a host auto-approvingReadOnlyParallelauto-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 — insideconnect, whichRuntimeBuilder::build_asynccalls
serially with no deadline above it. One such server hung the whole runtime
build rather than degrading its own connection. - A
tools/callwhose reply was lost on theapplication/jsonpath 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, sincestream_idle_timeoutalways
fires beforecall_tool_timeout. SinceMcpBridgedToolflattens the error
to itsDisplay, and onlyRequestIndeterminatesays "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
reportedSessionExpiredand 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 atools/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&'staticconstants, so no
server-controlled bytes reach a request header on that path at all. - Smaller: the
tools/listpage cap rejected a list exactlymax_tool_pages
long (andmax_tool_pages: 1rejected 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 afterinitializeorphaned the server-side session, andshutdown
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 theopenai-oauthfeature 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. Adocsjob now
builds the workspace withRUSTDOCFLAGS=-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-websocketis default-on specifically so a host that only streams
over HTTP+SSE can droptokio-tungstenitefrom 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 answer404on
/ssebecause they never implemented the older transport, and406on
/mcpbecause nothing in mentra sent anAcceptheader offering both reply
types. Such a server was reachable through neither existing transport, which
made an MCP integration impossible rather than degraded. McpStreamableHttpServerConfigandMcpStreamableHttpClientpost every
JSON-RPC message to the one configured endpoint and read each reply from that
same response, whether the server answers with a singleapplication/json
body or opens atext/event-streamin it.RuntimeBuildergains
with_mcp_streamable_http_serverandwith_mcp_streamable_http_servers, and
McpManagergainsconnect_streamable_http; the tools bridge exactly as the
other two transports' do.- The session
initializeassigns rides on every later request as
Mcp-Session-Id, andshutdownends 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/callwhose 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". A4xxis exempt — those are rejections
the server makes before dispatching the message — where a5xxis not,
because a server can fail after running the tool.
A permission request says what the call would do
ToolAuthorizer::authorizesaw 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::PermissionRequestedcarried apreviewfield 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.PermissionRequestednow carriesclassification: Option<ToolClassification>,
a new type that is the authorization preview with the call's input left off.
"Allow edits, refuse the network" isToolSideEffectLevel::LocalState
againstToolSideEffectLevel::External, readable from that field alone.- The field is
Optionrather than a defaultedToolClassificationbecause 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 carriesSome. ToolQueued.mutabilitystays hardcoded toUnknown, 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 readsclassification.- Breaking:
SessionEventis 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 returningModifyruns 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_tooltakes its tool by value, and neither of
ExecutableTool's sub-traits had a forwarding impl forBoxorArc— so a
host holding a tool behind a pointer, or wanting two runtimes to share one
instance, had no public path in.ToolAuthorizerhad had exactly this
treatment for some time; the tool traits had not.ToolDefinitionandToolExecutorare now implemented forBox<T>and
Arc<T>whereT: ?Sized, soBox<dyn ExecutableTool>and a sharedArc
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 forauthorization_previewit 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:
Boxis#[fundamental], so a downstream crate
could previously writeimpl ToolDefinition for Box<ItsOwnTool>and now
collides with mentra's blanket impl. There is noArcequivalent, 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 totool::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
McpSseServerConfigwrapped its header values so aDebugcould not print
them;McpServerConfigdid not — and stdio servers are where MCP credentials
actually live, since the de-facto.mcp.jsonformat puts tokens inenv.
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.McpServerConfignow has a hand-writtenDebugthat keeps the variable
names and replaces every value with[redacted]. The names are the half an
operator needs — "it hasGITHUB_TOKENset" is the useful part — and keeping
the field's type means this breaks nothing.Serializeis untouched, so a
config written back to disk still carries what it was given.SecretStringmoved tomcp::secret, shared by both HTTP transports rather
than owned by the SSE one, and deliberately does not implementSerialize:
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.