Skip to content

v0.11.0

Choose a tag to compare

@paulbalandan paulbalandan released this 10 Aug 10:55
· 206 commits to 1.x since this release
Immutable release. Only release title and notes can be modified.
v0.11.0
cf2c53b

What's Changed

Closes a family of security defects where peer-supplied bytes reached a renderer unbounded and unescaped, in a JSON-RPC error, a log record, or an exception message. The rest is decode correctness for names and keys the 2026-07-28 schema permits and this SDK refused. Three breaking changes are listed in BREAKING_CHANGES.md.

Added

  • SafeDisplay is now public API, for bounding and escaping a peer value your own handler quotes back.

Changed

  • AuthorizedHttpClient takes an HttpClientBuilder and runs credentialed traffic on a client that follows no redirect, so a hop off the MCP server's origin is refused before the credential travels (see BREAKING_CHANGES.md).
  • JwksAccessTokenValidator takes the issuer it accepts and refuses a token whose iss is absent or different, or which carries no exp (see BREAKING_CHANGES.md).

Fixed

  • Every peer value this SDK quotes back in a JSON-RPC error, a log record, or a client-side exception is bounded and escaped. error.data.uri and RemoteCallFailedException::$error still carry it whole.
  • A malformed envelope is no longer copied whole into the log record reporting it. The reason and the request id ride the logged exception as before.
  • A parsed OAuth scope is held to the RFC 6749 scope-token grammar, and a segment that is not one is dropped.
  • A resource-template variable that percent-decodes out of the segment it matched is refused rather than handed to the reader. files://%2E%2E%2Fetc resolved where files://../etc never matched.
  • A client reconnected to a new transport is no longer driven by the old one. disconnect() left its five listeners attached, so a stale error, reconnect, or message could still reach the live connection.
  • A header-mismatch retry no longer re-lists tools without bound. The walk stops on a repeated cursor or at 100 pages, and a tool it never reaches is retried unmirrored rather than with the rejected header.
  • A JSON Schema property name made only of digits is decoded rather than refused, and re-encodes as an object. Covers a tool's inputSchema and outputSchema, an elicitation's requestedSchema, and an elicit result's content, whose keys are those same names. An empty content now emits {} too. A JSON array arriving in one of those object-typed slots is normalised to an object rather than refused, since json_decode cannot tell it from an object whose names run 0n-1.
  • A tool or prompt argument name made only of digits is decoded rather than refused, and re-encodes as an object. Covers tools/call and prompts/get arguments plus completion/complete's context.arguments, whose names are the schema property names widened above, so a tool declaring such a property can now be called as well as listed. ParameterHeaderValidationMiddleware no longer drops those arguments before checking them against the Mcp-Param-* headers.
  • A server-assigned inputRequests / inputResponses key made only of digits is decoded rather than refused, and re-encodes as an object. The spec puts no format on those keys, so a server numbering them from a counter had its whole multi-round-trip exchange rejected. A JSON array in one of those slots is normalised to an object, for the same reason as above.
  • A tool's inputSchema and outputSchema emit an empty sub-schema as {} rather than [], at any nesting depth, so {"type":"object","properties":{}} survives a round trip as valid JSON Schema. An elicitation's requestedSchema does the same, including when it rides on a tools/call or tasks/get result, as do an elicit result's content and a tasks/get result's result and error. Those last two also take a key made only of digits, matching the rest of the class.
  • A _meta name made only of digits is decoded rather than refused. json_decode turns such a key into a PHP int, which the guard read as a malformed object. A name set running 0n-1 stays refused: it decodes identically to a JSON array, so the two cannot be told apart.
  • A tool can now return an array, string, number or boolean as its structured content, not only an object. CallToolResult previously refused everything but an object on both construction and decode.
  • A peer's tool, prompt, resource or resource-template name is decoded whatever characters it carries. The SDK still holds its stricter handle format on the names it authors, in ServerBuilder::addTool() and friends. A client can now also call a tool whose name sits outside that format.
  • A timestamp carrying any number of fractional-second digits is accepted, so a peer emitting microseconds (Python's isoformat()) or nanoseconds no longer has the whole payload rejected. Only 0 to 3 digits parsed before. Anything finer than a microsecond is truncated.
  • An emitted timestamp now carries microseconds rather than milliseconds, so a value survives a round trip. 2026-03-09T12:00:00.500+00:00 becomes …12:00:00.500000+00:00, and a sub-millisecond value is no longer flattened to .000.
  • An ISO 8601 parse failure caused by an overflowed date now names the field that carried it, rather than reporting a bare The parsed date was invalid.
  • A client reconnected to a different server no longer answers with the previous server's identity, nor refuses a typed call on the previous server's advertisement. disconnect() left both in place.
  • A stdio server no longer loses in-flight responses when the transport is closed explicitly. Server::run() could return while handlers were still running, and their sends were refused because the transport went Closed before draining.
  • A stdio transport closed before it was started, or in the same tick it was started, now fires onDrain before onClose like every other close path.
  • A resources/read handler receives the client's inputResponses and requestState. Both were parsed from the envelope and then dropped, so a resource could ask for input but never see the answer.
  • BearerAuthenticationMiddleware refuses a token whose expiresAt has passed. It took the validator's reported expiry on trust, so a custom validator's lapsed token was served. Pass expiryLeewaySeconds to match a validator configured for clock skew, such as one setting JWT::$leeway.
  • A PSR-7 host whose request body cannot rewind can serve tool calls. ParameterHeaderValidationMiddleware consumed the body while peeking at it, so the transport answered every POST with -32700 Parse error.
  • SecuredHttpEndpoint applies maxBodyBytes before the parameter-header middleware rather than after, so an oversized body is refused without first being buffered and JSON-decoded.
  • A discovered tool taking a mixed or untyped parameter is callable. It advertised [] as that property's schema, which is not a JSON Schema, so every tools/call failed before the executor ran.
  • Tool accepts boolean sub-schemas in inputSchema and outputSchema. One such entry previously failed the whole tools/list page.

Full Changelog: v0.10.0...v0.11.0