Skip to content
6 min read

An MCP Tool Schema Should Be Closed

An MCP tool schema should be closed: learn how JSON Schema, string limits, and server-side argument validation protect model calls.

An MCP Tool Schema Should Be Closed

An MCP tool schema should describe not everything your internal API can accept, but one safe request the model is allowed to make right now. If a tool accepts an arbitrary object, long text, and several implicit modes, the model will eventually send something extra. Sometimes it will be harmless noise. Sometimes it will pass someone else's identifier, an internal filter, an instruction from a document, or a parameter a developer left there «for later».

The problem is not that the model fails to follow instructions. An MCP tool receives arguments through a machine interface, and a machine interface must restrict input on its own. A prompt can ask the model «not to send internal fields», but the server must reject them if they appear. Otherwise, you have given the model broader permissions than you intended.

The Model Context Protocol specification defines inputSchema as JSON Schema for tool parameters. In the current MCP documentation, schemas without an explicit $schema use the JSON Schema 2020-12 dialect, and for a tool without parameters the documentation explicitly recommends an object with additionalProperties: false. This is the right starting point, but it does not replace server-side validation or make a broad operation safe by itself.

A broad object gives the model extra paths

A broad schema is dangerous because every optional parameter becomes another possible action the model can choose from context. Fields such as options, filters, metadata, query, payload, and params with type object and no nested schema are especially risky. They often appear as a convenient bridge to an existing API. In practice, that bridge exposes all of the API's old capabilities, including ones you never intended to give the agent.

Imagine a tool for preparing a refund:

{
  "name": "create_refund",
  "inputSchema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" },
      "options": { "type": "object" }
    },
    "required": ["order_id"]
  }
}

At first glance, the model should pass an order number. But options raises a more important question: which fields will the underlying service accept? refund_to_original_method, reason_code, amount, currency, override_limit, notify_customer, actor_id? Even if the server ignores some of them today, you are creating a dependency on accidental behavior. After the next internal API update, a supposedly harmless field may start working.

An open object also gives the model poor guidance. There is no clear boundary, so it tries to infer useful parameters from the tool description, documentation, email text, or the result of another function. The more guesses you allow, the more calls you have to investigate in your logs.

It is better to give each tool a short, specific job. For example, create_refund_draft could create only a draft for the order's full amount and accept neither the amount, nor the refund method, nor arbitrary metadata. If the business needs partial refunds, create another route with its own checks and confirmation. This is not unnecessary bureaucracy. It is a permission boundary that can be seen, tested, and explained to an auditor.

A closed schema must reject unknown fields

additionalProperties: false rejects properties that are not listed in properties and are not allowed through patternProperties. For a flat object, this is the most direct way to stop the model from passing an unrelated parameter «just in case».

Here is a tool schema that searches for a customer by one identifier only. It deliberately accepts neither a date range, nor a SQL-like filter, nor a flag for including personal data, nor an arbitrary set of fields.

{
  "name": "get_customer_summary",
  "description": "Возвращает краткую сводку по клиенту, доступному текущему пользователю.",
  "inputSchema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "customer_id": {
        "type": "string",
        "minLength": 1,
        "maxLength": 36,
        "pattern": "^[A-Za-z0-9_-]+$",
        "description": "Идентификатор клиента из текущего рабочего контекста."
      }
    },
    "required": ["customer_id"]
  }
}

This schema will reject the following call before execution:

{
  "customer_id": "cust_7D2k",
  "include_pii": true
}

It will also reject the typo customerId. That may seem strict, but silently accepting an almost-correct field is worse: the model thinks the operation succeeded with one meaning while the server executes another. If you need compatibility with an old name, normalize it in a separate adapter and do not leave both names open in the public schema.

You must repeat the restriction for every nested object. Here is a common mistake:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "recipient": {
      "type": "object",
      "properties": {
        "email": { "type": "string" }
      },
      "required": ["email"]
    }
  },
  "required": ["recipient"]
}

The root object is closed, but recipient remains open. role, api_key, send_copy_to, is_admin, and anything else can pass through if the application code reads it. The fix is simple: add additionalProperties: false inside recipient itself.

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "recipient": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "email": {
          "type": "string",
          "minLength": 3,
          "maxLength": 254
        }
      },
      "required": ["email"]
    }
  },
  "required": ["recipient"]
}

additionalProperties and unevaluatedProperties solve different problems

Developers often treat these two keywords as interchangeable. They look similar only in a simple schema. The difference appears when you build an object with allOf, oneOf, if, or $ref.

additionalProperties looks at properties and patternProperties in its own location in the schema. As a result, a base object with additionalProperties: false can reject a field that another subschema adds through allOf. Developers usually respond by repeating the properties in the outer schema. After a few such changes, the schema begins to drift from the real contract.

unevaluatedProperties: false works differently. It rejects properties that were not handled by applicable subschemas while evaluating the result. JSON Schema documentation specifically highlights this as a solution for extensible schemas that use composition.

For example, suppose you want to allow two different ways to address a ticket: by the short ticket_id, or by the project and number pair.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "oneOf": [
    {
      "properties": {
        "ticket_id": {
          "type": "string",
          "pattern": "^TKT-[0-9]{1,8}$"
        }
      },
      "required": ["ticket_id"]
    },
    {
      "properties": {
        "project": {
          "type": "string",
          "enum": ["billing", "support", "security"]
        },
        "number": {
          "type": "integer",
          "minimum": 1,
          "maximum": 99999999
        }
      },
      "required": ["project", "number"]
    }
  ],
  "unevaluatedProperties": false
}

Here oneOf is more than cosmetic. It rejects an ambiguous request in which the model passes both ticket_id and project with number. This is a separate security rule: when the server receives two ways to select an object, it must not guess which one takes priority.

But check your stack first. The MCP 2025-11-25 document and newer drafts describe JSON Schema 2020-12, including richer composition. Older SDKs, proxies, and validators often process only type, properties, and required. If a client does not support unevaluatedProperties, it may show the model a polished schema without enforcing your restriction where you expect it to. A schema in the repository is not the same as the schema that actually validates the request.

String limits reduce volume and ambiguity

A string field without restrictions means «accept any text of any length». An address, an identifier, an email subject, and a comment are four different contracts. Do not describe them with one generic type.

A length limit protects against more than oversized requests. It forces you to decide why the field exists. If a value does not fit into 64 characters, it is probably no longer an identifier. If creating a draft requires tens of thousands of characters, perhaps the model should pass a reference to a previously stored document or request user confirmation instead of putting everything into a tool argument.

For controlled values, use enum rather than free text. For example, a delivery mode should not be an open-ended string:

{
  "delivery_mode": {
    "type": "string",
    "enum": ["email", "sms", "none"]
  }
}

This is better than pattern: ".*" and a description saying «allowed options: email, sms, none». A description helps the model choose a value, but enum forces the server to reject email_and_sms, urgent_sms, and text copied from an external page.

For codes and identifiers, combine a length limit with an exact pattern. maxLength alone permits spaces, control characters, and phrases. pattern alone may permit a huge string that matches the regular expression. The restrictions complement each other.

Do not turn a regular expression into business logic. A schema can check the shape of a contract number, but it cannot establish that the contract exists, belongs to the current organization, and is accessible to the caller. When a regular expression tries to replace those checks, it becomes difficult to read and almost impossible to change safely.

Free text is sometimes necessary. In that case, set honest boundaries: minLength, maxLength, a clear purpose, and server-side sanitization. Never insert such text directly into SQL, a shell command, a URL, a query template, or a system prompt. A valid string can still be dangerous content for the next component.

The schema checks shape; the server checks permission and meaning

Keep tools vendor-neutral
Route requests to OpenAI, Anthropic, Google, DeepSeek, and other providers through one endpoint.

Server-side validation is mandatory because an MCP schema is not the execution boundary. A client may not support part of the dialect, skip validation because of an integration error, or send a call directly. Even a perfect schema does not know who owns an object or whether an operation is allowed in the current state.

The server needs three separate checks:

  1. It validates the complete input object against the same schema it publishes in tools/list.
  2. It derives the access subject from verified server-side authentication, not from user_id, tenant_id, or actor in the model's arguments.
  3. It checks business conditions before acting: record status, limits, idempotency, user consent, and the permitted state transition.

The separation matters. Consider an export request:

{
  "report_id": "rpt_4821",
  "format": "csv"
}

The schema confirms that report_id has an acceptable form and that format is on the list. Authorization determines whether this user can view report rpt_4821. The business check determines whether the report is complete, whether access has expired, and whether the export requires separate consent. If you mix these levels into one handler full of conditionals, the team starts fixing exceptions instead of the contract.

In practice, a server handler should look boring. That is a good sign.

const parsed = ExportReportInput.safeParse(request.arguments);
if (!parsed.success) {
  return {
    isError: true,
    content: [{ type: "text", text: "Некорректные аргументы инструмента." }]
  };
}

const principal = await requireAuthenticatedPrincipal(request);
const report = await reports.findVisibleTo(principal.orgId, parsed.data.report_id);

if (!report) {
  return {
    isError: true,
    content: [{ type: "text", text: "Отчёт недоступен." }]
  };
}

if (report.status !== "ready") {
  return {
    isError: true,
    content: [{ type: "text", text: "Отчёт ещё нельзя экспортировать." }]
  };
}

return exportReport(report, parsed.data.format);

Notice what is missing: trust in the model's claim, passing an organization identifier from the arguments, and putting the database exception text in the response. The external response should explain whether the request can be retried with different valid data. The internal log should retain the technical reason and a correlation ID for the team.

OWASP materials on agentic applications and LLM validation separately require validating the expected JSON structure and rejecting extra properties. This is not an audit formality. An unexpected field often becomes either a way around application logic or a channel through which untrusted text reaches a more privileged action.

A tool description is not a restriction mechanism

A good description is necessary, but it does not control the call. It affects how the model chooses a tool and forms arguments. The words «do not send personal data» will not stop a note field if your schema allows any string up to several megabytes. The words «use only the current customer» will not stop an arbitrary customer_id if the server does not compare it with the access scope.

Confusing descriptions with policy creates a particularly unpleasant mistake. The team reads the tool text, sees clear restrictions, and assumes they are enforced. Then another client, a new model, or an external instruction generates valid JSON with an unwanted field. The server faithfully executes what the code allowed it to execute.

Use the description for meaning and the schema for shape. Keep access policy in server code. For example:

  • description: «Creates a message draft for the selected recipient»;
  • schema: the recipient is specified only by a short identifier, the subject is limited to 120 characters, and unknown fields are rejected;
  • server: the recipient is accessible to the current user, the template is allowed, and sending is not started.

This separation also makes review easier. A security engineer can examine the explicitly restricted contract, while the process owner checks which actions are actually executed afterward.

A universal action usually blurs the boundary

Put models on one bill
Get B2B invoicing in tenge at provider rates with no API markup.

A popular recommendation sounds like this: create one manage_customer tool, add an action field, and put the parameters in data. Developers like this approach because it reduces the number of declarations. Models sometimes like it too because they see one familiar input. Security and maintainability, however, lose out.

Here is a form you should not publish:

{
  "type": "object",
  "properties": {
    "action": { "type": "string" },
    "data": { "type": "object" }
  },
  "required": ["action", "data"]
}

The schema does not say what data update_email needs, what merge_accounts needs, what delete_customer needs, or which operations are allowed without consent. The handler quickly fills with branches, each using a slightly different set of checks. After a few months, nobody is sure whether data is closed everywhere or whether the organization is checked consistently.

If the operations really differ in their impact, create separate tools. get_customer_summary should not be able to change an address. create_email_change_draft should not send an email. confirm_email_change should not accept the new address a second time; it should confirm an existing draft by its server-side identifier.

Sometimes a single tool is necessary, for example for a search with several mutually exclusive keys. In that case, express the modes through oneOf, restrict every branch, make the branches mutually exclusive, and keep permissions on the server anyway. The action field is not forbidden by itself. An open data object beside it almost always means the boundary was never properly designed.

Negative tests show whether the restriction works

Choose models without a new contract
Connect 500+ models through one API without expanding every MCP tool's contract.

A schema is not ready until it has rejection tests. A positive example proves that an expected request passes. It says nothing about whether an extra parameter, an invalid type, or an oversized string can reach the executor.

Give each tool a small set of negative cases. You do not need to build a generator for five fields, but you cannot stop at a happy-path test.

[
  {
    "name": "лишнее поле",
    "arguments": {
      "customer_id": "cust_7D2k",
      "include_pii": true
    },
    "valid": false
  },
  {
    "name": "слишком длинный идентификатор",
    "arguments": {
      "customer_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    },
    "valid": false
  },
  {
    "name": "объект вместо строки",
    "arguments": {
      "customer_id": { "value": "cust_7D2k" }
    },
    "valid": false
  },
  {
    "name": "допустимый запрос",
    "arguments": {
      "customer_id": "cust_7D2k"
    },
    "valid": true
  }
]

Run these examples against the validator that lives on the server, not only against the schema generated by the SDK. Then add an integration test: when the input is invalid, the mock for the external action must not be called. This catches a common mistake in which the code reads a raw field first and validates only the assembled structure afterward.

Also check version incompatibilities. If you use oneOf, conditional schemas, or unevaluatedProperties, send the real declarations through the clients and gateways that carry your traffic. The MCP specification is evolving, and your security depends on the specific validator, its version, and where it is connected.

For teams that call models through a single OpenAI-compatible gateway, this is another reason not to dissolve validation into the request router. AI Router can provide one path to models and key-level control, but the MCP tool contract and execution decision should remain next to the tool server. That is where the object, user, organization, and consequences of the call are known.

Reduce the contract first, then add convenience

Start designing a tool with this question: what is the smallest set of data needed to perform one useful action? Add a field only when there is a concrete scenario, a test, and an authorization rule. Do not add metadata, «settings for later», or optional flags just because they exist in the internal API.

If a field is needed only by the server, do not show it to the model. Take the organization identifier from the session. Determine region, plan, internal limits, and experiment flags on the server side. If the model must choose a value, offer a short enum or a separate safe identifier rather than a free-form object.

A strict schema will not make an agentic system error-free. It makes errors smaller: the model cannot silently expand the contract, and the server gets one clear reason to refuse. For tools that handle money, personal data, access rights, or external messages, this is not a minor refinement. It is standard engineering discipline.

Frequently asked questions

Is additionalProperties false enough for an MCP tool?

Yes. additionalProperties: false rejects fields that are not listed in properties, but only within the scope of that particular schema object. If you build an object with allOf, oneOf, or references, check how your validator behaves. Such schemas often need unevaluatedProperties: false at the outer level.

Why limit string lengths in MCP arguments?

Because an unlimited string gives the model almost unlimited room: it can contain extra context, instructions from external text, large lists, or accidental secrets. Set maxLength for every string field, and add pattern or enum for identifiers and codes.

Can JSON Schema replace server-side permission checks?

No. JSON Schema describes the acceptable shape of data, but it does not check user permissions, record state, account ownership, or whether an operation is allowed in the current business process. Before any side effect, the server must check the schema, authorization, and meaning of the request again.

Is one universal MCP tool better than several focused ones?

For a simple tool, several focused operations are usually better: get_customer, list_customer_invoices, and create_invoice_draft. A universal action parameter makes sense only when the variants really use the same inputs and access level. Otherwise, the branches quickly turn into ways around restrictions.

Should you trust format email and format uri in JSON Schema?

format is useful as a hint and an additional check, but format support depends on the validator. For critical values, do not rely only on format: email or format: uri: set a length, allowed host, and URL scheme, then validate the value in application code.

What should the server return when a tool call contains an extra field?

Return a structured error without internal details: a code, field name, and clear reason. Do not send SQL text, an exception stack, internal URLs, table names, or configuration fragments to the model. Such details rarely help fix the arguments and often make the next attack attempt more effective.

Can a dangerous argument be restricted only through its field description?

No, not when the model can send arbitrary text. A description influences generation, but it does not forbid an argument or limit its size. Restrictions belong in the schema and in the code that executes the action.

Do dangerous MCP calls need user confirmation?

For a local action such as creating a draft, a strict schema and ordinary logging may be enough. For a payment, deletion, publication, or access change, add a separate confirmation, a short-lived intent token, or a server-side workflow. Confirmation does not fix a broad schema, but it reduces the cost of an error after validation.

When should oneOf and if then else be used in an MCP schema?

Use them when the differences between variants matter. oneOf works well for mutually exclusive modes, such as searching by customer_id or by an approved email; if and then work for dependent fields. First make sure your MCP client and validator support JSON Schema 2020-12 rather than only basic properties and required.

How do you test a strict MCP tool schema?

Create negative tests beside the schema and run them in CI: an extra field, an empty string, an overly long string, an invalid enum value, an object instead of a string, and an unsupported branch. Then add integration tests proving that the server does not execute the action after any validation error. Positive examples check usability; negative examples check the access boundary.