# Tool Schema Validation

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Execution and Context
Source: https://www.devtutorials.ai/learn/mcp/execution-context/tool-schema-validation

> 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** (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

```js runnable title="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:

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

```js runnable title="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:

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

| Need | Constraint |
| --- | --- |
| A fixed set of choices | `enum` |
| A text format | `pattern` (a regular expression) |
| A length limit | `minLength`, `maxLength` |
| A number range | `minimum`, `maximum`, and `integer` for whole numbers |
| Must be present | `required` |

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

## Error Frames

### Which Error, When

```js runnable title="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:

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

```js runnable title="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:

```text
{
  "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.

```js runnable title="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:

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