MCP Server

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, and headers.
  • 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.sortField for a field the model never nested.
  • A tool whose arguments are all optional still can't be called with {}, because the body object 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:

JSONCode
"handler": { "export": "mcpServerHandler", "module": "$import(@zuplo/runtime)", "options": { "name": "example-mcp-server", "version": "1.0.0", "toolInputStyle": "flat", "operations": [ { "file": "./config/routes.oas.json", "id": "searchProducts" } ] } }

To give one tool a different style, set toolInputStyle in its route's x-zuplo-route.mcp block. The override works in either direction:

JSONCode
"x-zuplo-route": { "mcp": { "type": "tool", "toolInputStyle": "flat" } }

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:

  1. The tool's own toolInputStyle.
  2. The handler's toolInputStyle option.
  3. 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 setValue other than "flat" or "nested"
Handler toolInputStyle optionTreated as nested, with no error. Check the spelling.
x-zuplo-route.mcp.toolInputStyleThe build fails route schema validation.
x-zuplo-mcp-tool.toolInputStyleThe MCP server throws an error that names the operation when it registers.
Any of the above, as $env(...) valueDepends 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
MCP handler option "toolInputStyle" on route "/mcp" can't use $env(...): the build uses it to generate tool schemas. Use "flat" or "nested".

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:

JSONCode
{ "/stores/{storeId}/search": { "post": { "operationId": "searchProducts", "parameters": [ { "name": "storeId", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "schema": { "type": "integer" } }, { "name": "x-tenant", "in": "header", "required": true, "schema": { "type": "string" } } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "required": ["query"], "properties": { "query": { "type": "string" }, "sort": { "enum": ["price", "name"] } }, "additionalProperties": false } } } } } } }

The nested tool advertises one object per channel, and every channel is required:

JSONNested inputSchema
{ "type": "object", "properties": { "pathParams": { "type": "object", "properties": { "storeId": { "type": "string" } }, "required": ["storeId"], "additionalProperties": false }, "queryParams": { "type": "object", "properties": { "limit": { "type": "integer" } }, "required": [], "additionalProperties": false }, "body": { "type": "object", "properties": { "query": { "type": "string" }, "sort": { "enum": ["price", "name"] } }, "required": ["query"], "additionalProperties": false }, "headers": { "type": "object", "properties": { "x-tenant": { "type": "string" } }, "required": ["x-tenant"], "additionalProperties": false } }, "required": ["pathParams", "queryParams", "body", "headers"], "additionalProperties": false }

The flat tool advertises the same properties in one object:

JSONFlat inputSchema
{ "type": "object", "properties": { "storeId": { "type": "string" }, "limit": { "type": "integer" }, "query": { "type": "string" }, "sort": { "enum": ["price", "name"] }, "x-tenant": { "type": "string" } }, "required": ["storeId", "query", "x-tenant"], "additionalProperties": false }

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"}:

JSONFlat call
{ "storeId": "s1", "limit": 10, "query": "hoodie", "x-tenant": "acme" }
JSONNested call
{ "pathParams": { "storeId": "s1" }, "queryParams": { "limit": 10 }, "body": { "query": "hoodie" }, "headers": { "x-tenant": "acme" } }

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. required lists every property its own channel requires: a required parameter, or a name in the body's required list. A wrapped body, before compatibility date 2026-11-06, is the one exception: body is always required. Otherwise a tool whose inputs are all optional accepts {}.
  • Schemas are copied unchanged. Each property's schema, including its description, default, and enum, is copied exactly from the parameter or body property. Root $defs stay at the root, so a $ref inside 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 as X-Tenant is the argument x-tenant.
  • Output. Only the inputSchema is flattened. An outputSchema keeps 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 schemaFlat inputSchemaUndeclared argument
additionalProperties: falseadditionalProperties: falseRejected as an argument error
additionalProperties omittedNo additionalPropertiesForwarded in the request body
additionalProperties set to a schemaThe same additionalProperties schemaValidated, then sent in body
No request body, or a body that stays wrappedadditionalProperties: falseRejected 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
Invalid arguments for tool 'searchProducts': sort: must be equal to one of the allowed values

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.

CollisionExample
Query parameter and body propertyQuery id (string) and body property id (integer)
Path parameter and query parameterPath id and query id
Path parameter and body propertyPath storeId and body property storeId
Header parameter and another channelHeader x-tenant and body property x-tenant
A parameter named body and a wrapped bodyQuery parameter body and an array request body
A merged allOf property and a parameterPath 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
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)

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
MCP tool 'searchProducts' (operation searchProducts): toolInputStyle is "flat", but its request body can't be flattened and would stay under `body`: the `body` schema is not an object schema (`type` is "array"), so it has no properties to hoist. Declare the request body as an object with `properties` to have its fields become top-level tool arguments. 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)

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:

JSONCode
{ "type": "object", "properties": { "storeId": { "type": "string" }, "limit": { "type": "integer" }, "body": { "type": "array", "items": { "type": "string" } }, "x-tenant": { "type": "string" } }, "required": ["storeId", "body", "x-tenant"], "additionalProperties": false }

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 schemaNotes
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 dependentSchemasEach would also count or constrain the parameters at the flat root.
unevaluatedPropertiesFrom 2026-11-06, ignored in a draft-06 or draft-07 schema, which doesn't define it.
allOf at the rootFrom 2026-11-06, merged when it qualifies. See Composed bodies.
dependentRequiredFrom 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 schemaFrom 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, is type: "object" with only properties, 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 allOf except type: "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.

JSONBody
{ "allOf": [ { "$ref": "#/components/schemas/Base" }, { "type": "object", "properties": { "note": { "type": "string" } }, "required": ["note"] } ], "unevaluatedProperties": false }

With Base declaring a required id, the body flattens to:

JSONFlat body properties
{ "properties": { "id": { "type": "string" }, "note": { "type": "string" } }, "required": ["id", "note"], "additionalProperties": false }

The body can't be flattened when:

  • additionalProperties sits beside the allOf. It applies only to the root's own properties, so it doesn't close the merged object. Use unevaluatedProperties: false to close a composed body.
  • A branch carries anything else, such as additionalProperties, minProperties, patternProperties, or a nested oneOf.
  • Two branches declare the same property with different schemas.
  • unevaluatedProperties is anything other than false.

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:

JSONBody
{ "type": "object", "properties": { "card": { "type": "string" }, "billingZip": { "type": "string" } }, "dependentRequired": { "card": ["billingZip"] } }

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.

BehaviorBefore 2026-11-06From 2026-11-06
When the flat schema is builtWhen the MCP server startsAt build time
$env(...) in toolInputStyleResolved when the MCP server startsFails the build
Root allOf and dependentRequired bodiesBody wrappedFlattened when they qualify
Open body"additionalProperties": {}additionalProperties as written, or absent
Header argument namesAs declaredLowercase
Collision or body that can't be flattenedTool nested or body wrapped; runtime logFails the build
Argument valuesValidated per channel; defaults insertedValidated as a whole; passed through unchanged

Troubleshooting

Symptom or messageCauseFix
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 goA 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 nestedThe 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 logThe 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 callThe 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.

Last modified on