Skip to content

v0.5.2

Choose a tag to compare

@RomanEmreis RomanEmreis released this 12 Aug 10:22
· 4 commits to main since this release
a7fe2e5

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 port

allow_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 clientCapabilities are 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, making tools/call, resources/read and prompts/get unusable.
  • Elicitation params are written as the spec's union, not a tagged enum. {"Form": {...}} hid message and requestedSchema from every peer but neva. Both profiles.
  • MRTR inputResponses / requestState travel 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-Type twice, which drew 415 from strict receivers.
  • Schemas are published the way they were declared. Unmodelled keywords -- default, pattern, $ref, $defs and 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_scope re-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 closure

Tools 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, and App::run refuses to start when a tool and its handler disagree about arguments.
  • Legacy transport: each stream keeps its own Last-Event-ID cursor and retry: delay, a dropped POST reply is resumed once, a terminated session answers 404, 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-header registrations 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