Skip to content

Release 0.3.0: dual-era MCP support and the stateless workspace runtime - #68

Merged
cf-pages merged 18 commits into
mainfrom
release/0.3.0
Aug 13, 2026
Merged

Release 0.3.0: dual-era MCP support and the stateless workspace runtime#68
cf-pages merged 18 commits into
mainfrom
release/0.3.0

Conversation

@cf-pages

Copy link
Copy Markdown
Collaborator

Summary

  • Serves MCP 2026-07-28 (stateless, per-request _meta, server/discover, SEP-2243 mirror headers, cache hints) alongside the unchanged 2025-11-25 / 2025-06-18 handshake protocol, from one protocol-agnostic workspace runtime.
  • Removes HTTP sessions entirely: no Mcp-Session-Id, DELETE /mcp → 405, tools callable without a handshake; commands, retained output, and patch baselines are workspace-owned and survive client reconnects.
  • Removes the session-scoped default cwd (catalog 20 → 18), hardens the shared runtime against concurrent clients, reworks telemetry to request-scoped identity, and fixes patch line-boundary fidelity plus error-terminality rendering.
  • Contract: docs/runtime-contract-v0.3.md; migration guide: docs/migration-0.3.md; full details in the 0.3.0 CHANGELOG section.

Test plan

  • make lint / make typecheck / 274 unit tests / 127-test compliance suite green on every commit
  • Dual-era compliance suite incl. official mcp 2.0.0 SDK black-box smoke (stdio + HTTP)
  • v0.3.0 release pipeline green (validate, evidence ×4 incl. Windows, build, PyPI, GitHub Release)
  • 0.3.0rc1 manually verified end to end through a real ChatGPT developer-mode connector (18-tool scan, async command lifecycle across calls, git suite, patching, images)

Made with Cursor

cf-pages and others added 18 commits August 12, 2026 21:19
A connector that probes for a newer protocol, falls back to the legacy
handshake, and then re-sends initialize on the same STDIO process was
answered with -32600 Server is already initialized, which failed its tool
scan even though the session was healthy (issue #39). A repeat initialize
that negotiates the same version now replays the handshake result instead.

The replay goes through a new Runtime.initialize_result that builds the
payload without running the initializer, so no session state is reset and
the telemetry session count stays tied to real sessions. A repeat that asks
for a different protocol version is still rejected.

The regression test drives the reported sequence over stdio: an unsupported
server/discover probe, initialize with id 1, initialize with id 0, then
notifications/initialized and tools/list. It asserts only what the client
depends on -- the probe is answered on a live process and both handshakes
return the same result -- so the probe's error code stays free to change.

Co-authored-by: Cursor <cursoragent@cursor.com>
A client probing for a method this server does not implement was answered
with -32002 Server not initialized, which invites it to handshake and retry
a method that will never exist. dispatch_rpc now checks the request against
the set of implemented methods before it consults the handshake state, so an
unsupported method is reported as unknown in either state.

The handshake guard itself is unchanged: an implemented method other than
initialize or ping still returns -32002 before initialize.

Co-authored-by: Cursor <cursoragent@cursor.com>
…space root

get_default_cwd and set_default_cwd were the only tool-visible session state,
and their base could silently reset under a client reconnect. Relative paths
now always resolve against the workspace root, so the catalog drops to 18
tools, server_info no longer reports default_cwd, and the Workspace resolvers
lose their unused base-directory variants.

Co-authored-by: Cursor <cursoragent@cursor.com>
One runtime now answers requests from several clients at once, so the
per-request state it kept on itself is gone. The request-id to command_id
map was keyed by the client's own JSON-RPC id, so two clients that both
used id 1 could cancel each other's commands; notifications/cancelled is
still accepted and answered with silence, but it no longer terminates a
command (issue #48 tracks the responsiveness that costs). The threading
local that fed the map, cancel_request, and the now-unreachable
cancel_command go with it, and call_tool loses its request_id argument.

A frozen RequestContext carries the cross-cutting facts instead. Transports
build one per request in dispatch_rpc and tools/call hands it to call_tool
as a defaulted keyword argument, so a direct call_tool(name, arguments)
still works. The runtime does not read it: it only passes it to
emit_tool_trace for the observability work that will consume it. Freezing
is shallow, so the identity fields stay None until a validated immutable
copy exists to put in them.

Two shared structures are hardened. The runtime directory is resolved to
the primary or fallback tree exactly once under a lock, so a later mkdir
failure reports RUNTIME_DIR_UNWRITABLE instead of moving HOME, TMPDIR, and
the cache directory underneath a running command. The non-git diff fallback
snapshots its patch baselines under the patch lock rather than iterating a
dict another thread may be writing.

Co-authored-by: Cursor <cursoragent@cursor.com>
MCP 2026-07-28 drops the initialize handshake: a request states its own
protocol version, client capabilities, and optional client identity in
params._meta and is served on its own. Decide the era from the request
before the initialized guard runs, so such a request never needs a
handshake it cannot perform, and validate its _meta before dispatching.

The signal is deliberately narrow. Only the modern protocol version key
counts, so a legacy request carrying an unrelated _meta entry such as
progressToken keeps its old path, and initialize is always a handshake
whatever its _meta says. The two eras also keep their version lists
apart: a legacy version named in _meta is as unsupported as any other,
and -32022 offers back only the versions the stateless path accepts.

initialize itself now downgrades instead of failing. The handshake spec
asks a server to answer an unsupported protocolVersion with one of its
own, and returning -32602 turned a client that guessed wrong — or that
tried 2026-07-28 as a handshake — into a connection failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
A 2026-07-28 result is expected to declare that it is complete, to name
the server that produced it, and to say whether it may be cached. Add
one encoder that dispatch_rpc runs over every successful result before
the JSON-RPC envelope is built, rather than teaching each handler to
decorate its own return value: handlers keep returning business fields,
and no field can be added twice or forgotten by the next handler.

The encoder branches on era and nothing else. A legacy result is passed
straight through, so a handshake client sees exactly the bytes it saw
before. A modern result is copied before it is decorated, so the dict
the runtime built is never mutated. Only cacheable results get ttlMs
and cacheScope, and they go on the result root: a tool definition is a
schema clients validate, not a place for cache hints. isError is left
alone — a failed tool still answered completely, so resultType stays
complete.

Server identity comes from the runtime, since protocol.py cannot import
the server module, and the handshake now reads it from the same place.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sessions bought nothing once commands and the default cwd stopped being
session state: the id was a MAY the client only echoed back, while the
128-session ceiling, the idle expiry, and the handshake admission gate
were all failure modes of our own making. HTTP now answers every request
from the runtime that owns the workspace, ignores any Mcp-Session-Id a
client returns from an older server, and refuses DELETE with 405.

With no session to attach it to, the negotiated protocol version becomes
a per-request value: initialize negotiates one and answers with it as
often as it is asked, which is what a connector that probes, falls back,
and handshakes again needs, and telemetry still records one session per
process. server_info and the server card report the versions the server
speaks instead of the one a session had agreed on.

Co-authored-by: Cursor <cursoragent@cursor.com>
SEP-2243 has a 2026-07-28 request repeat its version, method, and
subject in headers so a gateway can route on them without reading the
body. We check the mirror against the body instead, which gives up part
of that intent: only the body can say which era a request belongs to,
and a handshake-era client sends none of these headers. A contradiction
between the two is one error, -32020, whichever header carries it.

Modern errors also reach the client as HTTP statuses now — -32601 as
404, and the request-fault codes as 400 — while -32603 and every
handshake-era error stay the 200 that clients of the older protocol
read their JSON-RPC error out of.

Co-authored-by: Cursor <cursoragent@cursor.com>
Telemetry still measured a client's handshake, which one shared runtime
no longer has: a 2026-07-28 client that never sends initialize left the
whole pipeline mute, and the client name recorded at the handshake only
ever described whichever client connected first. A session is now
activated by the first request or notification that passes envelope
validation, in either era and before the method runs — otherwise a first
call that fails would lose its tool_error — while ping never activates
one, so an HTTP health probe against an idle server stays silent.

Identity travels with the request that carried it. initialize emits its
own handshake event with the negotiated version and the clientInfo it
was given, once per handshake; a 2026-07-28 tool_error carries the
clientInfo of that request, narrowed to a printable ASCII subset and
truncated first, because the value is whatever the client says it is; a
handshake-era tool_error carries none rather than borrowing a name from
some other client. consecutive_failures and the 20-error budget are
runtime-wide, and the docs say so.

session_end gains the per-era request counts, the server/discover probe
count, and the retained-output counters that server_info used to answer
with — how often the budget was hit is a measurement of the process, not
an answer to whichever client asked. Each protocol choice a process
first serves is also logged as one line on stderr, telemetry on or off,
so an operator can see which era their clients speak.

Co-authored-by: Cursor <cursoragent@cursor.com>
The per-error paths of 2026-07-28 are covered request by request in
test_mcp_contract. What was missing is everything that only appears once
both eras share one server. A handshake-era exchange is now walked end to
end over both transports and every response is scanned recursively for a
modern key rather than compared against a byte golden that would break on
any harmless reordering, and each result's top-level key set is pinned:
a client of the older protocol validates against a schema that knows
those keys and no others.

The concurrency tests start from a barrier rather than looping: two HTTP
clients that both use id 1 must each read their own file back, a legacy
and a modern client asking for the same catalog must each get the shape
they asked for, two patches to one line must leave the file as one of
them wrote it and never a mix, two threads racing the first command must
agree on the runtime tree, and the non-git diff fallback must survive a
patch landing beside it.

The official python SDK is the only client here we did not write, so the
dev extra installs it and a smoke test drives both transports with it.
The SDK probes server/discover, is answered -32601, and falls back on its
own; the test asserts the server is usable and deliberately not that the
result is shaped for the new era, which is what enabling discover will
change. A missing SDK fails in CI and skips loudly elsewhere: a silent
skip would remove the one independent reading we have.

Co-authored-by: Cursor <cursoragent@cursor.com>
A 2026-07-28 client probes with server/discover instead of handshaking, so
until it is answered the dual-era clients this release exists for keep
falling back to the legacy handshake. The runtime returns the business
fields — the versions it speaks per request, the tools capability, and the
workspace instructions — and the modern encoder adds the result envelope,
including the conservative cache hints the instructions require: they quote
the workspace's own instruction files, so the result is never shared and
never reused.

A probe that carries no modern _meta stays unknown. Answering it would mean
guessing an era for a client that named none, and the -32601 it gets is what
sends it to initialize, a path that works.

Co-authored-by: Cursor <cursoragent@cursor.com>
The v0.2 contract described a server with sessions, a default cwd, and one
protocol; none of that is true any more, so v0.3 rewrites it around the two
eras: how a request picks one, what a modern _meta and its mirror headers must
carry, what server/discover answers, which error maps to which HTTP status,
and why the cacheable results are never shared. v0.2 is frozen where it is,
still describing 0.2.x, and the schema-drift gate now reads v0.3.

migration-0.3.md is the other half: every breaking change with what to do
instead, the two behavior changes that are not breaking but will surprise
someone, the compliance statement, and the warning that a workspace is one
trust domain. The rest of the docs are closed against the same grep — the
handshake is no longer the only way in, HTTP has no sessions to describe, and
the contract id in the release evidence is v0.3.

The 0.3.0 changelog section collects it all, with 0.2.3 recorded above 0.2.2
where it shipped, and the version files move to 0.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
… hygiene

Check a modern request's _meta before the mirror headers that repeat it, so an
unsupported version or a mistyped field answers with -32022/-32602 on HTTP as it
already did on stdio, instead of the -32020 the mirror would reach first. Reject
a mirror header sent more than once, and cap the base64 sentinel payload. Answer
a notification with nothing when its handling fails, on both transports, rather
than a null-id error. Stop deep-copying the client _meta: keep only the two
sanitized clientInfo strings anything reads, and drop the unused capabilities
copy that a nested payload could turn into a RecursionError. Sanitize the method
name in the first-appearance stderr line, tighten the clientInfo character set
so an address or path cannot travel verbatim, and build no telemetry event while
telemetry is off. Docs and CHANGELOG follow.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Both the file and the patch were split with str.splitlines(), which breaks
on \x0b \x0c \x1c \x1d \x1e \x85 \u2028 \u2029 as well as \n, and rejoined
with \n: any file holding one of those characters had it silently rewritten
by any patch, and a context line holding one could never match. Splitting on
\n is a bijection with the text, so the trailing-newline flag that made the
end of a file unaddressable is gone with it. An empty context line is now
read as the stripped ' ' it stands for.

Co-authored-by: Cursor <cursoragent@cursor.com>
retryable and category were only in structuredContent, which most clients
never forward to the model, so a permanent failure read exactly like a
transient one and dead command handles were retried until they timed out.
The error text now states both, tells the model not to repeat a call that
cannot succeed, and COMMAND_NOT_FOUND names exec_command as the way back.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@cf-pages
cf-pages merged commit 38f83b9 into main Aug 13, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant