Release 0.3.0: dual-era MCP support and the stateless workspace runtime - #68
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
2026-07-28(stateless, per-request_meta,server/discover, SEP-2243 mirror headers, cache hints) alongside the unchanged2025-11-25/2025-06-18handshake protocol, from one protocol-agnostic workspace runtime.Mcp-Session-Id,DELETE /mcp→ 405, tools callable without a handshake; commands, retained output, and patch baselines are workspace-owned and survive client reconnects.Test plan
make lint/make typecheck/ 274 unit tests / 127-test compliance suite green on every commitmcp2.0.0 SDK black-box smoke (stdio + HTTP)v0.3.0release pipeline green (validate, evidence ×4 incl. Windows, build, PyPI, GitHub Release)0.3.0rc1manually 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