v0.5.2
Neva now runs the official MCP conformance suite in CI, on both protocol generations. This release is mostly what it found: around fifty spec defects, several of which made neva unusable against anything but itself.
Conformance in CI
A conformance crate drives the suite against fixture server and client binaries, with the suite version pinned in one file that CI and the README both read. expected-failures-*.yaml is a strict two-way baseline: an unlisted failure fails the build, and so does a listed entry that starts passing, so every line is a debt with an owner.
Passing today: 155 server / 405 client checks on MCP 2026-07-28, 80 / 265 on the legacy profile. Every scored server scenario passes on both, with empty server baselines. What is left is one gap -- grant types beyond authorization_code (client credentials, DPoP, CIMD, JWT-bearer), tracked in #86.
Security
DNS-rebinding protection. A server on loopback is reachable by any page the browser loads: point evil.example.com at 127.0.0.1 and the browser connects. The spec makes validating Origin and Host a MUST for local servers, and neva did not. Bound to loopback it now answers only to loopback names and refuses anything else with 403 before reading the body; bound elsewhere it accepts everything as before, because the names a deployment is legitimately reached by are not knowable from here.
HttpServer::new("0.0.0.0:3000")
.with_allowed_origins(["https://app.example.com"]) // an origin: scheme, host and portallow_any_origin() turns the gate off for a deployment whose name is validated in front of the server.
SSE responses carry X-Content-Type-Options: nosniff. Without it Firefox buffers the stream to sniff its type, and a page reading it with fetch() sees nothing at all.
Interoperability
The fixes that decide whether neva talks to anyone else:
- Per-request
clientCapabilitiesare read as the spec's optional objects, not booleans. Every request from a conformant client -- MCP Inspector among them -- failed with-32602 invalid type: map, expected a boolean, makingtools/call,resources/readandprompts/getunusable. - Elicitation params are written as the spec's union, not a tagged enum.
{"Form": {...}}hidmessageandrequestedSchemafrom every peer but neva. Both profiles. - MRTR
inputResponses/requestStatetravel on the params, where the spec puts them. Against any other implementation the retry looked like a fresh call and the round-trip never completed. - The POST no longer carries
Content-Typetwice, which drew415from strict receivers. - Schemas are published the way they were declared. Unmodelled keywords --
default,pattern,$ref,$defsand the rest SEP-2106 requires to survive -- are kept verbatim instead of being dropped, at the root and below it.
Authorization
The authorization-code flow is green end to end: metadata discovery in all four layouts, dynamic registration, PKCE S256, every token-endpoint auth method, offline access, issuer migration and the RFC 9207 iss rules. Along the way:
- A
403 insufficient_scopere-authorizes and asks for the union of the existing grant and the demand (SEP-2350), so a step-up widens rather than trades. - What the session believes it holds follows the token: the granted scopes, kept across a renewal, and written to the token store so a step-up after a restart widens the stored grant.
- A stored refresh token survives a restart instead of sending the user back through consent.
- Protected Resource Metadata is looked for at the origin as well as RFC 9728's path-based location.
Breaking
PropertyType gains an Integer variant, and "integer" no longer deserializes into Number. The two are different types in JSON Schema -- integer rejects 1.5 -- and sharing a variant published a declared integer as number. A match needs the new arm; Schema::integer() builds one.
The schema structs in neva::types::schema gain an extra field, so an exhaustive struct literal needs it (or ..Default::default()). EnumOption gives up Eq with it; PartialEq stays.
Handler-argument bounds moved to FromHandlerArgs. App::map_tool / Tool::new take Args: FromHandlerArgs<CallToolRequestParams> and App::map_prompt / Prompt::new take Args: FromHandlerArgs<GetPromptRequestParams>, replacing the TryFrom<...> bounds. Handlers themselves are unaffected; a hand-written impl TryFrom<CallToolRequestParams> for MyArgs needs porting. ToolHandler::args returns Vec<ToolArg> (ordered, carrying each argument's required flag) and HandlerParams::Tool / Prompt carry the primitive's ArgNames.
Wire: a tool registered from a bare closure advertises arg0, arg1, ... instead of the former type names, because arguments are now extracted by name rather than by position. Name them:
// before: one `number` property, and `b` had nowhere to travel in
app.map_tool("add", |a: i32, b: i32| async move { a + b });
// after
map_tool!(app, "add", |a: i32, b: i32| async move { a + b }); // reads the names off the closureTools declared with #[tool] are unaffected -- they already published their parameter names, and now read by them.
Also
Context::client_capabilities()reports what the caller declared for this request, down to the elicitation mode, so a handler can branch before asking for an input it would be refused for.Option<T>tool and prompt arguments, andApp::runrefuses to start when a tool and its handler disagree about arguments.- Legacy transport: each stream keeps its own
Last-Event-IDcursor andretry:delay, a droppedPOSTreply is resumed once, a terminated session answers404, and a server offering no standalone SSE stream no longer ends the session. - MRTR: a round carries every input the handler asked for, an answer that does not fit is dropped rather than failing the call, and a final round that failed partway through its commits is not repeatable.
x-mcp-headerregistrations expire with the listing that carried them (SEP-2243 with SEP-2549's clock).
The full list is in CHANGELOG.md.
Full Changelog: 0.5.1...0.5.2