---
title: "Compatibility Date 2026-11-06"
description: "Compatibility date 2026-11-06 aligns request validation and the MCP Server handler with OpenAPI, JSON Schema, and MCP, and adds MCP 2026-07-28 support."
canonicalUrl: "https://zuplo.com/changelog/2026/11/06/compatibility-date-2026-11-06"
pageType: "changelog"
date: "2026-11-06"
tags: "runtime, policy"
---
Compatibility date `2026-11-06` is available. It brings request validation and
the MCP Server handler in line with the OpenAPI, JSON Schema, and MCP
specifications, and it turns on MCP protocol revision `2026-07-28` support in
the MCP Gateway and the MCP Server handler.

Your project doesn't change until you raise its compatibility date. New projects
don't use `2026-11-06` by default yet.

| Change                                                                                                                      | Affects                                                                         |
| --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| [Schema validation follows the declared specification](#schema-validation-follows-the-declared-specification)               | Request Validation policy, MCP Server handler tools and prompts, flat MCP tools |
| [OpenAPI path parameters end at the closing brace](#openapi-path-parameters-end-at-the-closing-brace)                       | OpenAPI routes with text after a `{param}` in one segment                       |
| [Blank policy options are treated as unset or rejected](#blank-policy-options-are-treated-as-unset-or-rejected)             | Built-in policies configured with an empty `$env(...)`                          |
| [MCP tool parameters use their OpenAPI serialization style](#mcp-tool-parameters-use-their-openapi-serialization-style)     | MCP tools whose parameters declare `style` or `explode`                         |
| [MCP tool names follow the MCP naming rule](#mcp-tool-names-follow-the-mcp-naming-rule)                                     | MCP tools with spaces, `/`, long, or repeated names                             |
| [MCP responses match the negotiated protocol version](#mcp-responses-match-the-negotiated-protocol-version)                 | MCP clients on protocol `2025-03-26` or with no version header                  |
| [MCP resource reads fail on error responses](#mcp-resource-reads-fail-on-error-responses)                                   | MCP resources whose route can return an error status                            |
| [Invalid MCP resource URIs fail the build](#invalid-mcp-resource-uris-fail-the-build)                                       | MCP resources with a custom `uri`                                               |
| [MCP protocol 2026-07-28](#mcp-protocol-2026-07-28)                                                                         | MCP Gateway projects and MCP Server handler routes                              |
| [MCP Gateway answers follow the MCP specification](#mcp-gateway-answers-follow-the-mcp-specification)                       | MCP clients that haven't connected an upstream                                  |
| [MCP Server handler checks prompt messages and batch answers](#mcp-server-handler-checks-prompt-messages-and-batch-answers) | MCP prompts and JSON-RPC batches                                                |

## How to adopt it

Set the date in `zuplo.jsonc`:

```jsonc
{
  "version": 1,
  "compatibilityDate": "2026-11-06",
}
```

Then:

1. Build locally with `zuplo dev` or push to a branch. Read the build output:
   several changes below turn a warning into a build error, and the build names
   the operation that needs attention.
2. Deploy the branch to a
   [preview environment](https://zuplo.com/docs/articles/branch-based-deployments)
   and run your tests against it, including any MCP clients you support.
3. Merge once it passes.

The date turns on every change below together. You can't turn off one change and
keep the rest. If you aren't ready for a change, keep your project on
`2026-03-01` (or its current date). Zuplo supports each compatibility date for
at least 12 months after release. See the
[version support policy](https://zuplo.com/docs/articles/version-support-policy#compatibility-dates).

## Schema validation follows the declared specification

Affects the
[Request Validation policy](https://zuplo.com/docs/policies/request-validation-inbound)
and the tools and prompts of the
[MCP Server handler](https://zuplo.com/docs/handlers/mcp-server).

**Before:** The MCP Server handler converts every tool and prompt schema at
startup. That conversion is lossy: keywords beside a combinator are dropped,
recursive references accept anything, and a string with `format: int64` removes
the tool from `tools/list`. Schema problems are ignored.

**After:** The build generates each tool and prompt schema from your OpenAPI
document, with precompiled validators, and validates every keyword of the JSON
Schema dialect and OpenAPI version the document declares.

| Behavior                     | Before                    | After                                   |
| ---------------------------- | ------------------------- | --------------------------------------- |
| `default` in a tool argument | Inserted into the request | Not inserted                            |
| Undeclared tool argument     | Dropped                   | Argument error                          |
| `readOnly` property in input | Kept                      | Left out of the input schema            |
| `format`                     | Partially checked         | Checked as each format is specified     |
| `pattern`                    | No Unicode (`u`) flag     | Unicode (`u`) flag                      |
| Recursive schemas            | Checked one level deep    | Checked at every depth                  |
| Declared `$schema` dialect   | Ignored                   | Honored                                 |
| Schema problems              | Ignored, or tool dropped  | The build fails and names the operation |
| Argument error text          | Zod wording               | Ajv wording                             |

Schema problems that fail the build include an input schema that's invalid for
its dialect, a `$ref` that doesn't resolve, a `pattern` that isn't a valid
Unicode regular expression, and a `$ref` cycle with no base case. A problem in
an output schema logs a warning and the tool is listed without an
`outputSchema`.

The Request Validation policy uses the same dialect, `format`, and `pattern`
rules as MCP tools, so a request and a tool call with the same data get the same
outcome. It keeps its parameter type coercion: `?limit=5` still validates
against `{ "type": "integer" }`. It also validates parameters declared on the
path item rather than the operation. Before this date those requests failed with
`Validator not found`.

[Flat tool input](https://zuplo.com/docs/mcp-server/flat-tool-input) can also
flatten a request body built from `allOf` when its branches are plain objects
that don't conflict, and a body that uses `dependentRequired`. Before this date
those bodies stay under a `body` argument.

A tool whose `toolInputStyle` is `"flat"` but that can't be fully flattened now
fails the build. That covers a name collision between two input channels, such
as a query parameter `id` and a body property `id`, and a request body that
would stay under `body`, such as an array body. Before this date, such a tool
builds and stays nested, or keeps its body under `body`, and the MCP server logs
why. The error names the tool, the operation, the cause, and the operation's
location, and suggests both fixes:

```text
MCP tool 'searchProducts' (operation searchProducts): toolInputStyle is "flat", but its input can't be flattened: `id` is declared by both `queryParams` and `body`, so the two cannot share one flat argument. Rename one of them to flatten it. Fix the schema, or set "toolInputStyle": "nested" on this tool (x-zuplo-route.mcp, or x-zuplo-mcp-tool in a files entry) to keep its input nested (config/routes.oas.json#/paths/~1stores~1{storeId}~1search/post)
```

For a body that can't be flattened, the message reads
``its request body can't be flattened and would stay under `body`: `` followed
by the reason.

**What to do:**

- If a route relies on a `default` value arriving from an MCP tool call, apply
  the default in the route or its handler.
- If models send arguments the operation doesn't declare, declare them or expect
  argument errors.
- Fix any schema the build reports. Some patterns that compile without the
  Unicode flag, such as ones with unnecessary escapes, fail with it.
- A `toolInputStyle` set with `$env(...)` fails the build, because the build
  generates the schemas from it. Use `"flat"` or `"nested"`.
- If the build reports a flat tool that can't be flattened, change its schema so
  it flattens, or set `"toolInputStyle": "nested"` on that tool in its
  `x-zuplo-route.mcp` block (or `x-zuplo-mcp-tool` for the deprecated `files`
  option). The server's other tools stay flat. See
  [Flat tool input](https://zuplo.com/docs/mcp-server/flat-tool-input#when-a-tool-stays-nested).

## OpenAPI path parameters end at the closing brace

Affects OpenAPI routes where letters, digits, `_`, or `$` directly follow a path
parameter in the same segment.

**Before:** The parameter name keeps going after the `}`:

```text
Path: /items/{id}_details

GET /items/42_details  → matches, request.params.id_details = "42_details"
GET /items/42          → matches, request.params.id_details = "42"
```

`request.params.id` is never set, and the route can shadow other routes in the
same segment. The build warns about each such path.

**After:** The parameter ends at its `}`:

```text
GET /items/42_details  → matches, request.params.id = "42"
GET /items/42          → 404
```

The MCP Server handler also fills in the parameter in the same position when it
calls the route. Paths without text directly after a parameter match exactly as
before.

**What to do:** Search the build output for the path warning. Update code that
reads the old, longer parameter name.

## Blank policy options are treated as unset or rejected

Affects built-in policies whose string option is set to `""` or only whitespace,
usually `"$env(VAR)"` with `VAR` defined but empty.

**Before:** Many policies reject the empty value for an optional option. For
example:

- The
  [HTTP Deprecation policy](https://zuplo.com/docs/policies/http-deprecation-outbound)
  with `"link": ""` fails every request with a configuration error.
- `mcp-auth0-oauth-inbound` with `"audience": "$env(AUTH0_AUDIENCE)"` and an
  empty variable stops the gateway from starting.
- An empty `authHeader` fails at request time, because `""` isn't a valid header
  name.

Each policy handles an empty required option in its own way, so any error that
follows doesn't name the option.

**After:** For an optional option, the policy behaves as if the option were left
out, and the option's default applies. This covers options such as `authHeader`
on the API Key, Open ID JWT, Monetization, and AI Gateway auth policies, `scope`
and `audience` on the MCP OAuth policies, `sunset` and `link` on HTTP
Deprecation, `clientIpHeader` on IP Address Restriction, and the upstream AWS,
OAuth client credentials, and Zuplo JWT auth options.

Secrets, options where an empty value means something, and options whose default
would grant more access keep their current behavior. A non-blank value is always
used as given.

For a required option, the policy isn't created. Requests to routes that use it
fail with a configuration error that names each empty required option:

```text
Policy 'my-oauth-policy': the required option 'clientSecret' is empty. Set it to a value, or check that the environment variable it reads is set.
```

When several required options are empty, the message lists them all:
`the required options 'clientId', 'clientSecret' are empty`. Three required
options keep using an empty value as given, because it means something there:
`match` and `replaceWith` on the
[Replace String policy](https://zuplo.com/docs/policies/replace-string-outbound),
and `body` on the
[Set Body policy](https://zuplo.com/docs/policies/set-body-inbound). The check
covers policies configured in `policies.json`, not built-in policies that custom
code creates directly.

**What to do:** For an optional option, nothing, unless you relied on a startup
or request failure to catch a missing environment variable. Set the variable, or
remove the option. For a required option, make sure every environment variable
it reads is set to a value in each environment before you raise the date.

## MCP tool parameters use their OpenAPI serialization style

Affects MCP tools whose operation declares `style`, `explode`, `allowReserved`,
or `content` on a path, query, or header parameter, or passes arrays or objects
in those parameters.

**Before:** The handler ignores the parameter's serialization settings:

| Location | Array `["blue","black","brown"]` | Object `{"R":100,"G":200,"B":150}` |
| -------- | -------------------------------- | ---------------------------------- |
| Query    | `c=blue&c=black&c=brown`         | `c=%7B%22R%22%3A100...` (JSON)     |
| Path     | `blue%2Cblack%2Cbrown`           | `%5Bobject%20Object%5D`            |
| Header   | `blue,black,brown`               | `[object Object]`                  |

A route that declares `pipeDelimited`, `deepObject`, `label`, `matrix`, or
`explode: false` gets a request it can't parse.

**After:** Each parameter is serialized as the OpenAPI specification's style
examples show:

```text
Query rgb: { style: deepObject, explode: true }   arguments: { "rgb": {"R":100,"G":200,"B":150} }
before   GET /swatches?rgb=%7B%22R%22%3A100%2C%22G%22%3A200%2C%22B%22%3A150%7D
after    GET /swatches?rgb[R]=100&rgb[G]=200&rgb[B]=150

Path tone: { style: label }   arguments: { "tone": ["blue","black","brown"] }
before   GET /swatches/blue%2Cblack%2Cbrown
after    GET /swatches/.blue.black.brown
```

Values are percent-encoded as UTF-8, and a space is `%20`.

**What to do:** If a route parses the old format, update it to read the format
its OpenAPI parameter declares.

## MCP tool names follow the MCP naming rule

Affects MCP tools whose name (`x-zuplo-route.mcp.name`, or the operation ID)
contains characters other than `A-Z`, `a-z`, `0-9`, `_`, `-`, and `.`, is longer
than 128 characters, or repeats another tool's name.

**Before:** Tools are advertised and called by their configured name as is. When
two tools share a name, for example a tool from the deprecated `files` option
and one from `operations`, the later one silently replaces the earlier one.

**After:** The handler rewrites names that break the MCP rule:

```text
configured             before                after
search items/v2        search items/v2       search_items_v2_2
search_items_v2        search_items_v2       search_items_v2
café ☕ menu            café ☕ menu           caf____menu
l × 140                l × 140               l × 128
lookup.item-v1         lookup.item-v1        lookup.item-v1
duplicate_name         duplicate_name        duplicate_name
duplicate_name (files) (replaces the above)  duplicate_name_2
```

Each character outside the allowed set becomes `_`, an empty name becomes
`tool`, and names are cut to 128 characters. A name that's already taken gets
`_2`, `_3`, and so on, in registration order. `tools/list`, `tools/call`, and
analytics use the new name, and `tools/call` no longer accepts the old name of a
rewritten tool.

The build warns about every such name on any compatibility date.

**What to do:** Set `x-zuplo-route.mcp.name` to a conforming name for each tool
the build warns about, so the name stays stable and doesn't depend on
registration order. Update any client or prompt that calls a tool by its old
name.

## MCP responses match the negotiated protocol version

Affects MCP clients that send `MCP-Protocol-Version: 2025-03-26`, or no
`MCP-Protocol-Version` header (which the MCP transport treats as `2025-03-26`).

**Before:** Every response carries every field, so a `2025-03-26` client gets
`outputSchema` and `title` on `tools/list` and `structuredContent` on
`tools/call`. Strict clients reject these responses.

**After:** The request's protocol version decides which fields the handler
sends:

- Before `2025-06-18`: tools leave out `outputSchema` and `title`, resources
  leave out `title`, and `tools/call` results leave out `structuredContent`.
  When such a result has no text block, its JSON is added as one, so the client
  still gets the data. `resource_link` content blocks become text blocks that
  hold the link as JSON, and annotations leave out `lastModified`.
- Before `2025-11-25`: tools leave out `icons` and `execution`, and resources
  and resource links leave out `icons`.
- Before `2025-03-26`: tools also leave out `annotations`.

`_meta` is sent on every version.

```text
tools/call, no MCP-Protocol-Version header

before                                      after
{ "content": [{ "type": "text",             { "content": [{ "type": "text",
    "text": "{\"sku\":\"a-1\",\"count\":3}" }],   "text": "{\"sku\":\"a-1\",\"count\":3}" }],
  "structuredContent":                        "_meta": {} }
    { "sku": "a-1", "count": 3 },
  "_meta": {} }
```

Clients on `2025-06-18` or `2025-11-25` see no change.

**What to do:** If a client reads `structuredContent`, make sure it sends
`MCP-Protocol-Version: 2025-06-18` or later. Most MCP SDKs do.

## MCP resource reads fail on error responses

Affects MCP resources whose route can return a status outside `200`-`299`, for
example a resource protected by an auth policy.

**Before:** The route's response body is the resource's contents whatever the
status. A `403` problem+json body reaches the client as a successful read.

**After:** `resources/read` returns a JSON-RPC error and doesn't send the
route's body:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32603,
    "message": "Resource 'private-doc' (docs://private) could not be read: its route answered 403",
    "data": { "uri": "docs://private", "status": 403 }
  }
}
```

A redirect status counts as an error. A `2xx` response is returned exactly as
before.

**What to do:** If a client treats an error body as content, branch on the
JSON-RPC error and `error.data.status` instead.

## Invalid MCP resource URIs fail the build

Affects MCP resources whose `x-zuplo-route.mcp.uri` (or the deprecated
`x-zuplo-mcp-resource.uri`) isn't an absolute
[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) URI.

**Before:** The build warns, and the handler advertises the URI as written, for
example `audit doc with spaces`. Clients that validate the URI reject the
resource or the whole list.

**After:** The build fails with the same message:

```text
MCP resource URI "audit doc with spaces" of operation "bad" is not an absolute RFC 3986 URI
```

Valid URIs include `docs://guide`, `urn:isbn:0451450523`, and
`https://example.com/x`. Relative paths, spaces, and non-ASCII characters aren't
valid. A resource without a `uri` gets the default `mcp://resources/<name>`,
which is always valid.

**What to do:** Fix every URI the build warns about before raising the date, for
example `docs://audit-doc` or a percent-encoded form.

## MCP protocol 2026-07-28

Affects projects that use the
[MCP Gateway](https://zuplo.com/docs/mcp-gateway/overview) or the
[MCP Server handler](https://zuplo.com/docs/handlers/mcp-server).

### MCP Gateway

**Before:** The MCP Gateway relays `2026-07-28` traffic without handling it as
that revision. An upstream that answers only `server/discover` is reported as
unavailable, and a caller that hasn't connected the upstream gets the `-32042`
URL-elicitation error, which `2026-07-28` clients can't act on.

**After:** The MCP Gateway handles requests that name `2026-07-28` as that
revision requires:

- Upstream readiness falls back to `server/discover` when an upstream refuses
  `initialize`.
- The gateway answers `server/discover` itself for a caller that hasn't
  connected the upstream, and adds the configured server icons.
- A caller that hasn't connected the upstream gets an `input_required` result
  with a URL-mode connect elicitation for `tools/call`, `prompts/get`, and
  `resources/read` (or `-32021` when the client doesn't support URL
  elicitation). The list methods return an empty list with `ttlMs: 0` and
  `cacheScope: "private"`, so the client lists again after it connects.
- Before the gateway answers a request itself, it checks it as a `2026-07-28`
  server does. A request without a required `MCP-Protocol-Version`,
  `Mcp-Method`, or `Mcp-Name` header gets HTTP `400` with `-32020`, and the
  methods `2026-07-28` removed, such as `ping`, get HTTP `404` with `-32601`.
- The gateway's own `server/discover` reply lists the protocol versions the
  upstream serves once the gateway has seen them.
- On `subscriptions/listen`, the request timeout limits only the wait for
  response headers, so the stream can stay open. A client disconnect cancels the
  upstream request.

Requests on `2025-11-25` and earlier also get the changes in
[MCP Gateway answers follow the MCP specification](#mcp-gateway-answers-follow-the-mcp-specification),
and these project-wide changes:

- Authorization responses from the gateway's OAuth authorization server include
  the RFC 9207 `iss` parameter, and its metadata advertises
  `authorization_response_iss_parameter_supported: true`. An MCP SDK client that
  validates `iss` rejects a callback without it.
- The configured server icons are also added to an `initialize` reply that the
  upstream sends as an event stream, not only a JSON one.
- On Managed Edge and in local development (`zuplo dev`), `request.signal`
  aborts when the client disconnects. This applies to all code in the project,
  not only the MCP Gateway. Work you register with `context.waitUntil` still
  runs to completion.

**What to do:** If custom code listens to `request.signal`, make sure it handles
an abort on client disconnect. See
[MCP Gateway compatibility dates](https://zuplo.com/docs/mcp-gateway/code-config/compatibility-dates).

### MCP Server handler

**Before:** The MCP Server handler supports protocol revisions up to
`2025-11-25`. It answers a request with `MCP-Protocol-Version: 2026-07-28` with
HTTP `400` and JSON-RPC error `-32000`.

**After:** The handler serves a request whose `MCP-Protocol-Version` header is
`2026-07-28` on that revision:

- `server/discover` returns `supportedVersions` (`2026-07-28`, then `2025-11-25`
  through `2024-10-07`), the server's `capabilities`, and its name and version
  in `_meta["io.modelcontextprotocol/serverInfo"]`. A client can negotiate down
  to an earlier revision, which it opens with `initialize`.
- Every result carries `resultType: "complete"` and the server's name and
  version. The `server/discover`, list (including `resources/templates/list`),
  and `resources/read` results also carry `ttlMs: 0` and
  `cacheScope: "private"`.
- `initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, and
  `resources/unsubscribe` return HTTP `404` with `-32601`, because `2026-07-28`
  removed them.
- A missing `_meta` envelope returns HTTP `400` with `-32602`, headers that are
  missing or disagree with the body return `-32020`, and an unsupported revision
  returns `-32022`.
- A JSON-RPC batch or a posted JSON-RPC response returns HTTP `400` with
  `-32600`, because `2026-07-28` has neither. The `406` and `415` transport
  errors carry `-32600` instead of `-32000`.
- An unknown tool, prompt, or resource returns `-32602` with the name or URI in
  the message, for example `Tool nope not found` or
  `Resource not found: docs://nope`.

A request that names `2026-07-28` only in its `params._meta` envelope, without
the `MCP-Protocol-Version` header, returns HTTP `400` with `-32020`, because
`2026-07-28` requires the header. A request whose `_meta` names `2025-11-25` or
an earlier revision is served on that revision. Requests on `2025-11-25` and
earlier get the same answers as before, apart from the changes in
[MCP Server handler checks prompt messages and batch answers](#mcp-server-handler-checks-prompt-messages-and-batch-answers).

The handler doesn't serve tools that ask the client for input (`input_required`
results, elicitation, sampling, and roots), `subscriptions/listen`
notifications, logging, or tasks.

**What to do:** If a hook or policy answers a `tools/call` request itself, add
`resultType: "complete"` to its result when the `MCP-Protocol-Version` header is
`2026-07-28`. If it rewrites the tool name in `params.name`, also set the
`Mcp-Name` header to the new name, or the handler returns HTTP `400` with
`-32020`. Nothing changes for existing clients. A `2026-07-28` client sends the
`MCP-Protocol-Version` and `Mcp-Method` headers, an `Mcp-Name` header for a
tool, prompt, or resource, and the `_meta` envelope. See
[MCP protocol 2026-07-28](https://zuplo.com/docs/handlers/mcp-server#mcp-protocol-2026-07-28)
in the handler docs.

## MCP Gateway answers follow the MCP specification

Affects MCP clients that call an
[MCP Gateway](https://zuplo.com/docs/mcp-gateway/overview) route before they
connect its upstream, and routes with the `mcp-token-exchange-inbound` policy.

**Before:** A caller that hasn't connected the upstream gets the `-32042`
URL-elicitation error with HTTP `200` for every message, including
notifications, `ping`, malformed bodies, `GET` requests, and clients that never
declared URL elicitation. When the upstream rejects the credential the gateway
sent, the client gets the upstream's `401` or `403` and its `WWW-Authenticate`
challenge, and reads it as its own gateway token failing.

**After:** The answers follow the MCP specification:

| Request from a caller that hasn't connected the upstream                  | Before             | After                                                    |
| ------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------- |
| `initialize` on `2025-11-25` that declares `capabilities.elicitation.url` | `-32042`           | `-32042`                                                 |
| Any other request, such as `tools/call`                                   | `-32042`           | `-32603` with the connect link in `data.connectRequired` |
| `ping`                                                                    | `-32042`           | An empty result                                          |
| Notification or JSON-RPC response                                         | `-32042`, no `id`  | HTTP `202` with no body                                  |
| Empty body, or a body that isn't JSON                                     | `-32042`           | HTTP `400` with `-32700`                                 |
| Message that isn't valid JSON-RPC                                         | `-32042`           | HTTP `400` with `-32600`                                 |
| `MCP-Protocol-Version` the route doesn't serve                            | `-32042`           | HTTP `400` with `-32000`                                 |
| JSON-RPC batch on `2025-06-18` or later                                   | `-32042`           | HTTP `400` with `-32600`                                 |
| JSON-RPC batch on `2025-03-26`                                            | One `-32042` error | One answer for each message                              |
| `GET` or `DELETE`                                                         | `-32042`           | HTTP `405` with `Allow: POST`                            |

On a route with the `mcp-token-exchange-inbound` policy, an upstream that still
answers `401` after the gateway refreshed its credential, or answers `403` with
a `WWW-Authenticate` challenge, gets this answer to the client:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32603,
    "message": "The upstream MCP server rejected the gateway's credential.",
    "data": { "upstreamStatus": 401 }
  }
}
```

A route without the policy relays the upstream's response as before.

**What to do:** If a client relied on `-32042` for a `tools/call`, read the
connect link from `data.connectRequired.authUrl`. See
[MCP Gateway compatibility dates](https://zuplo.com/docs/mcp-gateway/code-config/compatibility-dates#gateway-answers-follow-the-mcp-specification).

## MCP Server handler checks prompt messages and batch answers

Affects [MCP prompts](https://zuplo.com/docs/mcp-server/prompts) and clients
that send JSON-RPC batches to an MCP Server handler route.

**Before:** `prompts/get` returns the prompt route's messages as the route sends
them, including a `role` such as `system` or a content block MCP doesn't define.
A batch whose answer holds one response gets a bare object, not an array. A tool
from the deprecated `files` option with a blank name is registered with an empty
name.

**After:** `prompts/get` returns JSON-RPC error `-32603`, naming the prompt and
the first invalid message, when a message's `role` isn't `user` or `assistant`,
or its content isn't a block the client's protocol version defines. For example,
an `audio` block needs `2025-03-26` or later. The answer to a batch is always an
array. A blank tool name falls back to the operation's `operationId`.

**What to do:** Return a `role` of `user` or `assistant` from prompt routes.

## Other MCP improvements

These shipped alongside the new date and work on every compatibility date.

### OpenTelemetry tracing for MCP

The [MCP Server handler](https://zuplo.com/docs/handlers/mcp-server) and the MCP
Gateway now record spans that follow the OpenTelemetry semantic conventions for
MCP. When you
[configure OpenTelemetry](https://zuplo.com/docs/articles/opentelemetry), each
MCP request, including `initialize`, `ping`, and notifications, gets a span
named after its method and target, such as `tools/call get_weather`, with
attributes such as `mcp.method.name`, `gen_ai.tool.name`, the session ID, and
the protocol version.

- The MCP Gateway records a server span for the client's request and a client
  span for the upstream call, and passes its trace context to the upstream in
  `params._meta`, so a trace continues into an upstream MCP server that reads
  it.
- When a client sends trace context in `params._meta`, the span continues that
  trace, or links to it when the request already belongs to another trace.
- The MCP Gateway's server span records the client's IP address as
  `client.address`, as the MCP Server handler's span does.
- To record tool arguments and results on `tools/call` spans, set
  `telemetry.captureToolCallContent` in the handler options of an MCP Server
  handler route or an MCP Gateway route. It's off by default because arguments
  and results often contain personal data.

```json
{
  "handler": {
    "export": "mcpServerHandler",
    "module": "$import(@zuplo/runtime)",
    "options": { "telemetry": { "captureToolCallContent": true } }
  }
}
```

On an MCP Gateway route, set the same option on `McpProxyHandler`:

```json
{
  "handler": {
    "export": "McpProxyHandler",
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "options": {
      "rewritePattern": "https://mcp.example.com/mcp",
      "telemetry": { "captureToolCallContent": true }
    }
  }
}
```

The gateway records `gen_ai.tool.call.arguments` (`{}` when the call has no
arguments) and, for a result without `isError: true`, `gen_ai.tool.call.result`
on both its server and client spans, as JSON capped at 8,192 characters. A
response the gateway builds itself is never recorded as a tool result. With
capture on, the gateway reads `tools/call` responses up to 1 MiB instead of 64
KiB, so larger results are still recorded. See
[Capture tool call content](https://zuplo.com/docs/articles/opentelemetry#capture-tool-call-content).

### MCP method and tool on request logs

For a request to an MCP Server handler or MCP Gateway route, the request's
**Properties** in the
[log viewer](https://zuplo.com/docs/articles/logging#viewing-logs-in-the-portal)
now include the MCP method as `mcp.method.name`, such as `tools/call` or
`server/discover`, and the tool name as `gen_ai.tool.name` for a tool call. They
don't count toward your custom log property limit.

### Upstream connections survive token endpoint outages

When an upstream's token endpoint returns a `5xx` or `429` error, or doesn't
respond, during a refresh, the MCP Gateway keeps the stored connection and
returns HTTP `503` with the problem code `upstream_token_refresh_unavailable`.
The client can retry, and users no longer have to reconnect. When an upstream no
longer recognizes the gateway's OAuth client, even with a non-standard error,
the gateway registers a new client before it asks the user to reconnect, when it
registers its client automatically. See
[Upstream OAuth](https://zuplo.com/docs/mcp-gateway/auth/upstream-oauth#refresh-and-401-retry).

### Flat tool input

Set `toolInputStyle` to `"flat"` on the MCP Server handler, or on one tool, to
advertise a tool's path, query, header, and body fields as top-level arguments.
Some models send a body's fields at the top level even when a tool advertises
them under `body`, which costs a failed call and a retry; flat input removes
that wrapper. The handler routes each argument back to the part of the request
it came from. From compatibility date `2026-11-06`, a flat tool that can't be
fully flattened fails the build and names the fix, as described in
[Schema validation follows the declared specification](#schema-validation-follows-the-declared-specification).
On earlier dates, it keeps the nested shape, or its body under `body`, and logs
why. See [Flat tool input](https://zuplo.com/docs/mcp-server/flat-tool-input).

## Getting started

Set `"compatibilityDate": "2026-11-06"` in `zuplo.jsonc`, test on a preview
environment, and merge. For every change in each compatibility date, see
[Compatibility dates](https://zuplo.com/docs/programmable-api/compatibility-dates#2026-11-06).