# MCP Server Flat Tool Input

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies to every Zuplo hosting option: Managed Edge, Managed Dedicated, and Self-Hosted. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).

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](./tools.mdx#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:

```json
"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:

```json
"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 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).    |

### Environment variables

From compatibility date
[`2026-11-06`](../programmable-api/compatibility-dates.mdx#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:

```text
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:

```json
{
  "/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:

```json title="Nested 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:

```json title="Flat 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"}`:

```json title="Flat call"
{
  "storeId": "s1",
  "limit": 10,
  "query": "hoodie",
  "x-tenant": "acme"
}
```

```json title="Nested 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](../programmable-api/compatibility-dates.mdx#2026-11-06).

### 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](#when-the-body-stays-under-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`](../programmable-api/compatibility-dates.mdx#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 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`](../programmable-api/compatibility-dates.mdx#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`:

```text
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`](../programmable-api/compatibility-dates.mdx#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`](../programmable-api/compatibility-dates.mdx#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:

```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)
```

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`](../programmable-api/compatibility-dates.mdx#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:

```text
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:

```json
{
  "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`](../programmable-api/compatibility-dates.mdx#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](#composed-bodies).      |
| `dependentRequired`                                                                                                                                | From 2026-11-06, flattened when it qualifies. See [Composed bodies](#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`](../handlers/mcp-server.mdx#configuration) is on.

## Composed bodies

From compatibility date
[`2026-11-06`](../programmable-api/compatibility-dates.mdx#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 `$ref`s, 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.

```json title="Body"
{
  "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:

```json title="Flat 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](#when-a-tool-stays-nested).

### `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`:

```json title="Body"
{
  "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](../programmable-api/compatibility-dates.mdx#2026-11-06).
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](#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`](#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](./troubleshooting.mdx).
