Tool Schema Validation

By the Syncfusion DevTutorials team · Last updated

Validating MCP tool calls on client and server: missing, wrong-type, out-of-range and unknown arguments; schema design; error frames; output validation; and partial results.

At an airport, your bag may be checked at two points. The airline checks its size at the desk; security checks what is inside. These checks look for different problems, so one does not replace the other.

MCP tool calls can be checked on both sides too. The client can validate arguments before sending them. The server must validate every request it receives before using the arguments. When the server returns structured output, it or its SDK can check that output against the declared outputSchema.

The main idea: a schema describes the shape and rules for input or output. Validate at each trust boundary. When a check fails, return clear feedback in the right way so the host or model can respond.

A Real-World Example

The Brightgrid model kept calling export_report with {"rows": "500"}: a string instead of a number. An older server version tried to use it anyway. The text "500" was treated as text, and the report came out empty, with no error.

The team made three changes:

  1. The schema now says rows is an integer between 1 and 5,000.
  2. The server validates every call and returns a clear tool error for invalid arguments.
  3. The host validates too. It can catch a mistake before making a network request and send the problem back to the model.

With these checks in place, the invalid value is rejected instead of producing an empty report. The model can read the error and try again with a corrected value.

In simple words: check early, check on the server, and explain every failure.

What You Will Learn

In this lesson, you will learn:

  1. Why both the client and the server check arguments.
  2. The common argument problems: missing, wrong type, out of range, unknown.
  3. How to design schemas that are hard to misuse.
  4. How to write error messages a model can act on.
  5. How output checks catch broken results.
  6. How to return partial results.

Try the broken calls in this lesson in the MCP Lab Explorer. Then open Server code, change a schema, press Update live, and try again.

Client and Server Validation

Once a tool schema exists, the next step is to use it. Validation is the process of comparing real arguments and results against the rules described by that schema.

Why Check Twice

The client may check first to save a trip. The server must check before touching the backend, and it checks its own output too. Failures go back to the model as tool errors.

  • The client (in the host) checks so it can catch mistakes early, without a network trip, and send them back to the model fast.
  • The server checks because it can't trust any client. A buggy or bad client can send anything. The server protects the backend.

The client's check is a convenience. The server's check is required.

Common Problems

A small schema checker

// A small JSON Schema checker: type, required, enum, length, range, pattern, unknown keys.
function validate(schema, args) {
  const errors = [];
  for (const key of schema.required ?? []) {
    if (!(key in args)) {
      errors.push(`${key}: required`);
    }
  }
  for (const [key, value] of Object.entries(args)) {
    const rule = schema.properties[key];
    if (!rule) {
      if (schema.additionalProperties === false) {
        errors.push(`${key}: unknown property`);
      }
      continue;
    }
    const type = Number.isInteger(value) ? "integer" : typeof value;
    if (rule.type !== type && !(rule.type === "number" && type === "integer")) {
      errors.push(`${key}: expected ${rule.type}, got ${type}`);
      continue;
    }
    if (rule.enum && !rule.enum.includes(value)) {
      errors.push(`${key}: must be one of ${rule.enum.join(", ")}`);
    }
    if (rule.minLength !== undefined && value.length < rule.minLength) {
      errors.push(`${key}: at least ${rule.minLength} characters`);
    }
    if (rule.minimum !== undefined && value < rule.minimum) {
      errors.push(`${key}: at least ${rule.minimum}`);
    }
    if (rule.maximum !== undefined && value > rule.maximum) {
      errors.push(`${key}: at most ${rule.maximum}`);
    }
    if (rule.pattern && !new RegExp(rule.pattern).test(value)) {
      errors.push(`${key}: must match ${rule.pattern}`);
    }
  }
  return errors.length ? errors : ["ok"];
}

const schema = {
  type: "object",
  properties: {
    order_id: { type: "string", pattern: "^A-\\d{4}$" },
    rows: { type: "integer", minimum: 1, maximum: 5000 },
  },
  required: ["order_id"],
  additionalProperties: false,
};
console.log(validate(schema, { order_id: "A-1002", rows: 10 }).join("; "));
console.log(validate(schema, { rows: 9000 }).join("; "));
console.log(validate(schema, { order_id: 1002, color: "red" }).join("; "));
console.log(validate(schema, { order_id: "B-12" }).join("; "));

Expected output:

ok
order_id: required; rows: at most 5000
order_id: expected string, got integer; color: unknown property
order_id: must match ^A-\d{4}$

These are the validation errors you will encounter most often when building MCP tools. The four classic problems:

  • Missing required: order_id was not sent.
  • Type errors: a number where text was expected, or text where a number was expected.
  • Out of range or format: rows: 9000 when the maximum is 5,000, or "B-12" when the pattern wants A- and four digits.
  • Unknown properties: an argument the schema doesn't mention.

The pattern says an order id must start with A- and have four digits after it, so "1002" is refused.

Unknown Properties

What should happen to an argument the schema doesn't list? There are two choices:

  • Strip it: quietly drop it, and run with the rest. The SDK's z.object(...) does this.
  • Reject it: refuse the call. JSON Schema calls this additionalProperties: false, and z.strictObject(...) produces it.

Strip or reject unknown properties

// Unknown properties: the same arguments under two schema styles.
const args = { order_id: "A-1001", include_secret_notes: true };
const known = ["order_id"];

// z.object(...) style: unknown keys are quietly dropped.
const stripped = Object.fromEntries(
  Object.entries(args).filter(([k]) => known.includes(k)),
);
console.log("strip :", JSON.stringify(stripped));

// z.strictObject(...) style (additionalProperties: false): unknown keys are an error.
const unknown = Object.keys(args).filter((k) => !known.includes(k));
console.log(
  "strict:",
  unknown.length ? "error, unknown property " + unknown.join(", ") : "ok",
);

Expected output:

strip : {"order_id":"A-1001"}
strict: error, unknown property include_secret_notes

Rejecting is safer for tools that change things: a typo like priorty is then caught instead of ignored. Stripping can be convenient for some read-only lookups.

Schema Design

Validation works best when schemas are designed clearly. A good schema helps both the model and the people using the tool understand what values are expected.

Unambiguous Names

Argument names should leave no room to guess: order_id, not id. start_date, not date. If two tools take the same thing, give it the same name in both.

Specific Descriptions

Describe the format of each argument: "The order id, like A-1001", "A date as YYYY-MM-DD". A model fills in arguments much better with an example.

Constraints

Descriptions help explain what is expected, but constraints give the model and the validator rules that can be checked automatically.

Use the schema, not just words:

NeedConstraint
A fixed set of choicesenum
A text formatpattern (a regular expression)
A length limitminLength, maxLength
A number rangeminimum, maximum, and integer for whole numbers
Must be presentrequired

Constraints in the schema are visible to the model in tools/list. A model that sees enum: ["low", "high"] rarely sends "urgent". The same rule written only in the tool's code is invisible until the call fails.

Error Frames

Which Error, When

Error or isError?

// Three ways a call can go wrong, and what the client gets back.
const cases = [
  [
    "unknown tool name",
    { error: { code: -32602, message: "Tool refund_all not found" } },
  ],
  [
    "arguments break the schema",
    {
      result: {
        content: [{ type: "text", text: "Input validation error: ..." }],
        isError: true,
      },
    },
  ],
  [
    "tool code throws",
    { result: { content: [{ type: "text", text: "database is down" }], isError: true } },
  ],
  [
    "output breaks outputSchema",
    {
      result: {
        content: [{ type: "text", text: "Output validation error: ..." }],
        isError: true,
      },
    },
  ],
];

for (const [what, reply] of cases) {
  const kind = reply.error
    ? `JSON-RPC error ${reply.error.code}`
    : "result with isError: true";
  console.log(what.padEnd(27), "->", kind);
}

Expected output:

unknown tool name           -> JSON-RPC error -32602
arguments break the schema  -> result with isError: true
tool code throws            -> result with isError: true
output breaks outputSchema  -> result with isError: true
  • An unknown tool is a protocol error (-32602): the client asked for something that doesn't exist.
  • Bad arguments, a failed backend, or a broken result are tool errors: a normal result with isError: true, so the model can read it.

Messages a Model Can Use

The goal of a validation error is not just to say that something failed. It should give enough information for the model to understand the problem and correct the next call.

A good tool error says:

  1. What is wrong, naming each argument.
  2. What is allowed.
  3. Ideally, an example of a correct call.

A helpful tool error

// Turn validation problems into a tool error the model can act on.
function toolError(toolName, problems, example) {
  return {
    content: [
      {
        type: "text",
        text: `Invalid arguments for ${toolName}: ${problems.join("; ")}. Example of a valid call: ${JSON.stringify(example)}`,
      },
    ],
    isError: true,
  };
}

const r = toolError(
  "create_ticket",
  ["summary: at least 5 characters", "priority: must be one of low, high"],
  { summary: "Lamp arrived broken", priority: "high" },
);
console.log(JSON.stringify(r, null, 2));

Expected output:

{
  "content": [
    {
      "type": "text",
      "text": "Invalid arguments for create_ticket: summary: at least 5 characters; priority: must be one of low, high. Example of a valid call: {\"summary\":\"Lamp arrived broken\",\"priority\":\"high\"}"
    }
  ],
  "isError": true
}

Don't put stack traces, file paths or database details in error messages. They help an attacker more than the model.

Output Validation

Malformed Output

Validation is not only for input. Bugs can exist in tool implementations too. Output validation catches incorrect data before it reaches the client or model.

A tool with an outputSchema promises a shape. If its code returns something else, like a status of "lost" when only three statuses are allowed, programs that trust the shape will break.

The SDK checks structuredContent against the outputSchema before the result leaves the server. If it doesn't match, the caller gets a tool error saying "Output validation error" instead of bad data. A careful client can check again on its side.

Never "fix" bad output by quietly removing the output schema. The error is telling you that the tool's code and its promise disagree. Fix the code, or change the promise and tell the people who rely on it.

Partial Results

Partial results allow useful work to be preserved even when part of a batch operation fails.

Some tools handle several items at once, like looking up three orders. If one fails, don't throw away the two that worked:

  • Return the successes.
  • List the failures clearly.
  • Set isError: true only if nothing worked.

Return partial results

// Partial results: look up several orders, and report which ones failed.
const orders = { "A-1001": "shipped", "A-1002": "processing" };

function lookupMany(ids) {
  const found = [],
    failed = [];
  for (const id of ids) {
    (orders[id] ? found : failed).push(id);
  }
  const lines = found.map((id) => `${id}: ${orders[id]}`);
  if (failed.length) {
    lines.push(`Not found: ${failed.join(", ")}`);
  }
  return {
    content: [{ type: "text", text: lines.join("\n") }],
    structuredContent: { found: found.map((id) => ({ id, status: orders[id] })), failed },
    isError: found.length === 0, // only a full failure is an error
  };
}

const r = lookupMany(["A-1001", "A-1099", "A-1002"]);
console.log(r.content[0].text);
console.log("isError:", r.isError, "| failed:", r.structuredContent.failed.join(","));

Expected output:

A-1001: shipped
A-1002: processing
Not found: A-1099
isError: false | failed: A-1099

Common mistakes

  • Trusting the client's check. Clients can be buggy or hostile. The server checks every time.
  • Type coercion. Turning "500" into 500 without saying so hides model mistakes. Validate, and let the model fix them.
  • Vague errors. "Invalid input" gives the model nothing to fix. Name the argument and the rule.
  • Mixing up errors. Bad arguments are tool errors (isError), not JSON-RPC errors.
  • All or nothing. If 9 of 10 items worked, return the 9, and report the 1.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • The server must validate every call. The client may validate first to save a trip.
  • Watch for missing, wrong type, out of range and unknown arguments.
  • z.object strips unknown properties. z.strictObject (additionalProperties: false) rejects them.
  • Design schemas with clear names, example-rich descriptions and real constraints.
  • Unknown tool: protocol error. Bad arguments or a failed run: isError result with a helpful message.
  • The SDK checks output against outputSchema. Return partial results when some items succeed.

Next: Resource URIs, schemes, templates, data formats and caching.