MCP Server Flat Tool Input
- Applies to
- Gatewayruntime for API, AI, and MCP gateways
- Hosting
- Managed EdgeManaged DedicatedSelf-Hosted
The MCP Server handler turns each OpenAPI operation into an MCP tool. The
toolInputStyle option decides the shape of that tool's inputSchema:
nested(default) - Each input channel the operation declares is its own object argument:pathParams,queryParams,body, andheaders.flat- Path, query, and header parameters and the request body's properties all become top-level arguments of one object.
This page is the full reference for flat: how arguments map back to the HTTP
request, which schemas flatten, and what happens when one can't. For a shorter
introduction, see Tool input schemas.
Why flat input exists
With nested, every argument sits one level deep. A search tool takes
{"body": {"query": "hoodie"}}, not {"query": "hoodie"}. Some models,
particularly less capable ones, send the body's fields at the top level anyway.
Against a nested schema that first call fails validation, and the model retries
with the wrapper. That costs:
- An extra round trip on every call.
- Validation errors such as
body.sortFieldfor a field the model never nested. - A tool whose arguments are all optional still can't be called with
{}, because thebodyobject itself is required.
Flat input removes the wrapper so the advertised schema matches what those
models send. MCP itself doesn't require either shape: it only requires
inputSchema to be a JSON Schema object, and capable models generally follow
whatever schema a tool advertises. Flat input is a Zuplo convenience, so it's
opt-in.
Turn on flat input
Set toolInputStyle in the MCP Server handler options to set the style for
every tool on the server:
Code
To give one tool a different style, set toolInputStyle in its route's
x-zuplo-route.mcp block. The override works in either direction:
Code
If the MCP server uses the deprecated files option instead of operations,
set toolInputStyle in the operation's x-zuplo-mcp-tool extension.
Each tool uses the first value it finds:
- The tool's own
toolInputStyle. - The handler's
toolInputStyleoption. nested.
The default is nested on every compatibility date. A published inputSchema
is a contract with the clients built against it, so Zuplo doesn't change it
unless you ask.
| Where the value is set | Value other than "flat" or "nested" |
|---|---|
Handler toolInputStyle option | Treated as nested, with no error. Check the spelling. |
x-zuplo-route.mcp.toolInputStyle | The build fails route schema validation. |
x-zuplo-mcp-tool.toolInputStyle | The MCP server throws an error that names the operation when it registers. |
Any of the above, as $env(...) value | Depends on the compatibility date. See below. |
Environment variables
From compatibility date
2026-11-06, the
build generates each tool's schemas from toolInputStyle, so the value must be
a literal "flat" or "nested". A value that contains $env(...), on the
handler or on a tool, fails the build:
Code
On earlier compatibility dates, the build doesn't read toolInputStyle, and the
MCP server resolves $env(...) when it starts.
How arguments map to the request
This page uses one example operation, POST /stores/{storeId}/search. It
declares a path parameter, a query parameter, a required header, and a JSON
body:
Code
The nested tool advertises one object per channel, and every channel is required:
Nested inputSchema
The flat tool advertises the same properties in one object:
Flat inputSchema
Both calls below send the same request to the route,
POST /stores/s1/search?limit=10 with the header x-tenant: acme and the body
{"query":"hoodie"}:
Flat call
Nested call
The exact bytes of an advertised schema can differ slightly from these examples,
for example a root $schema, depending on the
compatibility date.
Rules of the flat schema
- Order. Properties appear in this order: path parameters, query parameters, body properties, then headers.
- Required.
requiredlists every property its own channel requires: a required parameter, or a name in the body'srequiredlist. A wrapped body, before compatibility date2026-11-06, is the one exception:bodyis always required. Otherwise a tool whose inputs are all optional accepts{}. - Schemas are copied unchanged. Each property's schema, including its
description,default, andenum, is copied exactly from the parameter or body property. Root$defsstay at the root, so a$refinside a property still resolves. - Routing. Each argument goes back to the channel that declared it: a path parameter fills the URL path, a query parameter the query string, a header parameter a request header, and a body property the request body.
- Header names. A header argument uses the header parameter's name. From
compatibility date
2026-11-06, header names are lowercase, so a header declared asX-Tenantis the argumentx-tenant. - Output. Only the
inputSchemais flattened. AnoutputSchemakeeps the response schema's shape.
Undeclared arguments
Your request body schema decides what happens to an argument that no parameter or body property declares:
| Body schema | Flat inputSchema | Undeclared argument |
|---|---|---|
additionalProperties: false | additionalProperties: false | Rejected as an argument error |
additionalProperties omitted | No additionalProperties | Forwarded in the request body |
additionalProperties set to a schema | The same additionalProperties schema | Validated, then sent in body |
| No request body, or a body that stays wrapped | additionalProperties: false | Rejected as an argument error |
Before compatibility date
2026-11-06, an open
body publishes "additionalProperties": {}, which also permits any extra
argument.
Set additionalProperties: false on a request body schema to reject fields a
model invents rather than pass them upstream.
An argument becomes a request header only when a header parameter declares that
exact name. An undeclared argument never becomes a header, even one named
headers or authorization. With an open body, it lands in the body as a field
of that name.
A header argument overrides an inbound header of the same name that the MCP client sent to the gateway. A required header parameter is a required tool argument, so the model must send it even when the MCP client's own request already carries that header.
Nested calls still work
A flat tool also accepts the nested form, so clients written against the nested
schema keep working after you switch to flat. The MCP server picks how to read
the arguments from their top-level keys:
- Only channel names (
pathParams,queryParams,body,headers) that aren't also flat properties: read as nested first, then as flat. If both fail, the nested errors are reported. - Anything else: read as flat first. If that fails and every key is a channel name, read as nested. If both fail, the flat errors are reported.
A call is never read as half flat and half nested. For the example operation,
{"storeId": "s1", "body": {"query": "hoodie"}} is read as flat and fails,
because body isn't a flat property and query is missing.
When a channel name is also a flat property, the flat reading wins. For a body
that declares its own body property, {"body": {"body": {}}} is a flat call,
and the route receives the body {"body": {}}.
Validation errors
A flat call is validated against the advertised flat schema as a whole, and
errors use flat paths. A bad sort value reports sort, not body.sort:
Code
Clients on MCP protocol version 2025-11-25 or later receive this as a tool
result with isError: true. Earlier versions receive a JSON-RPC -32602 error
with the same message. Either way, the route isn't called. The wording after the
colon comes from the validator and isn't a stable contract. Before compatibility
date 2026-11-06, it
uses a different format.
When a tool stays nested
When two channels declare the same property name, one flat argument can't carry both values, so the tool's input can't be flattened. Zuplo doesn't rename or prefix properties to resolve a collision.
| Collision | Example |
|---|---|
| Query parameter and body property | Query id (string) and body property id (integer) |
| Path parameter and query parameter | Path id and query id |
| Path parameter and body property | Path storeId and body property storeId |
| Header parameter and another channel | Header x-tenant and body property x-tenant |
A parameter named body and a wrapped body | Query parameter body and an array request body |
A merged allOf property and a parameter | Path id and an allOf branch that declares id (from 2026-11-06) |
From compatibility date
2026-11-06, a
collision on a tool whose toolInputStyle is flat fails the build. The error
names the tool, the operation, the property, the two channels, and the
operation's location:
Code
To fix it, rename one of the two properties, or set "toolInputStyle": "nested"
on that tool in its route's x-zuplo-route.mcp block (or in x-zuplo-mcp-tool
for the deprecated files option). The rest of the server's tools stay flat.
On earlier compatibility dates, the whole tool keeps the nested shape, and the MCP server logs a warning each time it registers the tool, naming the property and the two channels. The reason is never sent to MCP clients.
When the body stays under body
Some request bodies have no property names to hoist, or constrain the object as a whole in a way that would also count the parameters. Those bodies can't be flattened.
From compatibility date
2026-11-06, such a
body on a tool whose toolInputStyle is flat fails the build. For the example
operation with an array body ({"type": "array", "items": {"type": "string"}}),
the error is:
Code
To fix it, change the body schema so it flattens, or set
"toolInputStyle": "nested" on that tool.
On earlier compatibility dates, the body stays under a body argument (a
wrapped body), and body is required. The parameters still flatten. With the
example parameters and the array body, the flat schema is:
Code
A call looks like
{"storeId": "s1", "x-tenant": "acme", "body": ["hoodie", "scarf"]}.
A body can't be flattened when its schema matches any row of this table. Rows
marked "from 2026-11-06" apply from compatibility date
2026-11-06.
| Body schema | Notes |
|---|---|
type isn't exactly "object": an array, a string or other primitive, or ["object", "null"] | From 2026-11-06, a body that omits type also can't be flattened. |
properties isn't an object | |
oneOf, anyOf, not, if, then, or else at the root | |
minProperties, maxProperties, patternProperties, propertyNames, or dependentSchemas | Each would also count or constrain the parameters at the flat root. |
unevaluatedProperties | From 2026-11-06, ignored in a draft-06 or draft-07 schema, which doesn't define it. |
allOf at the root | From 2026-11-06, merged when it qualifies. See Composed bodies. |
dependentRequired | From 2026-11-06, flattened when it qualifies. See Composed bodies. |
const, enum, dependencies, $id, $defs, or definitions at the root (from 2026-11-06) | |
$dynamicRef, $recursiveRef, or a $ref with other keywords beside it, at the root (from 2026-11-06) | |
required names a property that properties doesn't declare (from 2026-11-06) | |
A root $dynamicAnchor (or 2019-09 $recursiveAnchor: true) that a $dynamicRef (or $recursiveRef: "#") in the body targets (from 2026-11-06) | The recursion would require the parameters again at every nested level. |
A media type with no schema | From 2026-11-06, can't be flattened. Before 2026-11-06, the tool has no body argument. |
Before compatibility date 2026-11-06, the MCP server decides from the schema
after converting it internally, and that conversion doesn't keep every keyword.
A root allOf body always stays wrapped, and dependentRequired is never
copied to the flat root.
A body that is only a $ref to a component, such as
{"$ref": "#/components/schemas/Order"}, isn't wrapped. The reference is
replaced by its target first, so it flattens like any other object body.
Before compatibility date 2026-11-06, a wrapped body is expected for these
shapes, so the MCP server logs it only at debug level, and only when the
handler's debugMode is on.
Composed bodies
From compatibility date
2026-11-06, two
composed body shapes flatten instead of staying wrapped. On earlier
compatibility dates, both stay wrapped.
For either shape, the MCP server validates the flat call against the flat schema
and then validates the routed body against your original body schema. A call
succeeds only when both accept it, so the flat tool never accepts a body your
OpenAPI document rejects. An error from the second check reports nested paths,
such as body: must have required property 'card'.
Root allOf
A body whose root is an allOf merges into one object when all of these are
true:
- Every branch, after following local
$refs, istype: "object"with onlyproperties,required, and annotations (title,description,examples,$comment,deprecated). - No property is declared by two branches with different schemas. The same schema in two branches is fine.
- The root has nothing beside
allOfexcepttype: "object",unevaluatedProperties: false, and annotations.
The merged required is the union of the branches' required lists.
unevaluatedProperties: false closes the merged object with
additionalProperties: false. In a draft-06 or draft-07 schema,
unevaluatedProperties doesn't exist, so the merged object stays open.
Body
With Base declaring a required id, the body flattens to:
Flat body properties
The body can't be flattened when:
additionalPropertiessits beside theallOf. It applies only to the root's ownproperties, so it doesn't close the merged object. UseunevaluatedProperties: falseto close a composed body.- A branch carries anything else, such as
additionalProperties,minProperties,patternProperties, or a nestedoneOf. - Two branches declare the same property with different schemas.
unevaluatedPropertiesis anything other thanfalse.
After merging, the body follows the same rules as any other body: a merged property that collides with a parameter is a collision.
dependentRequired
A body's dependentRequired is copied to the flat root unchanged when every
name it mentions is one of the body's own properties:
Body
The flat schema carries "dependentRequired": { "card": ["billingZip"] }, so
{"storeId": "s1", "card": "4242", "billingZip": "94103"} succeeds and
{"storeId": "s1", "card": "4242"} is an argument error.
When dependentRequired names something the body doesn't declare, such as a
parameter, the body can't be flattened: at the flat root, a parameter could
satisfy the rule.
Content types
Flattening reads only the request body's schema, not its media type. An
application/x-www-form-urlencoded body with an object schema flattens the same
way as a JSON body, and a text/plain body with type: "string" can't be
flattened.
An operation with several request content types gets one tool per content type,
named <name>_<content type>, such as createOrder_application_json. Each tool
is planned on its own, so one can flatten while another can't. Before
compatibility date 2026-11-06, the other tool keeps a wrapped body. From
2026-11-06, it fails the build, and "toolInputStyle": "nested" in the
operation's x-zuplo-route.mcp block applies to every tool of that operation.
The MCP server serializes the request body as JSON for every content type, and
sets the request's content-type header to the tool's content type.
Behavior by compatibility date
Compatibility date 2026-11-06 changes how the MCP Server handler builds and
validates tool schemas, and that includes flat input. The new behavior applies
to projects with a compatibilityDate of 2026-11-06 or later. See
Compatibility Dates.
The default toolInputStyle is nested either way.
| Behavior | Before 2026-11-06 | From 2026-11-06 |
|---|---|---|
| When the flat schema is built | When the MCP server starts | At build time |
$env(...) in toolInputStyle | Resolved when the MCP server starts | Fails the build |
Root allOf and dependentRequired bodies | Body wrapped | Flattened when they qualify |
| Open body | "additionalProperties": {} | additionalProperties as written, or absent |
| Header argument names | As declared | Lowercase |
| Collision or body that can't be flattened | Tool nested or body wrapped; runtime log | Fails the build |
| Argument values | Validated per channel; defaults inserted | Validated as a whole; passed through unchanged |
Troubleshooting
| Symptom or message | Cause | Fix |
|---|---|---|
Warning log MCP tool input schema left nested with a reason such as `id` is declared by both `queryParams` and `body`, so the two cannot share one flat argument. | Two channels declare the same name, before compatibility date 2026-11-06. | Rename one of them. See When a tool stays nested. |
Warning log MCP tool input schema left nested with `body` is declared as a parameter by `queryParams` and is also where this operation's request body has to go | A parameter named body and a wrapped body, before compatibility date 2026-11-06. | Rename the body parameter, or make the body a plain object. |
Warning log MCP tool input schema left nested with the build resolved this tool's toolInputStyle to "nested", so it has no flat schema. | The tool is flat at runtime, but the build read nested. | Set toolInputStyle to the literal "flat" in the handler options or the route's x-zuplo-route.mcp. |
Debug log MCP tool request body left nested | The body can't flatten, before compatibility date 2026-11-06. | Expected for that body shape. See When the body stays under body. |
Build error MCP tool '<name>' (operation <id>): toolInputStyle is "flat", but its input can't be flattened: ... | A collision, from compatibility date 2026-11-06. | Rename one of the two properties, or set "toolInputStyle": "nested" on that tool. |
Build error MCP tool '<name>' (operation <id>): toolInputStyle is "flat", but its request body can't be flattened and would stay under `body`: ... | A body that can't flatten, from compatibility date 2026-11-06. | Change the body schema so it flattens, or set "toolInputStyle": "nested" on that tool. |
Build error MCP handler option "toolInputStyle" on route "/mcp" can't use $env(...) | $env(...), from compatibility date 2026-11-06. | Use the literal "flat" or "nested". |
MCP tool configuration error: invalid toolInputStyle "Flat" on operation "searchProducts". Use "flat" or "nested". | A value other than "flat" or "nested" in x-zuplo-mcp-tool. | Use lowercase "flat" or "nested". |
| Tools stay nested with no log | The handler option has a typo, such as "Flat", which is read as nested. | Use lowercase "flat". |
must NOT have additional properties (color) on a flat call | The body sets additionalProperties: false and the call sent an undeclared argument. | Expected. Remove additionalProperties: false from the body to forward extra fields. |
Each build message ends with the location of the operation in parentheses, for
example (config/routes.oas.json#/paths/~1things/post).
For general argument errors and client debugging, see MCP Server Troubleshooting.