MCP Tool JSON Schema: Define and Validate Agent Tools

How to design MCP-style tool definitions, write tight argument JSON Schema, validate tool calls with Zod before side effects, and avoid the patterns that turn agent demos into production incidents.

17 min readAgents, MCP, and JSON

The short answer

An agent tool is a named verb plus a JSON Schema for its arguments. Models propose calls; your host must validate those arguments before any file write, HTTP request, or database change. Treat tool JSON as untrusted input—the same way you treat free-form LLM text and chat structured output.

In this guide

  1. Tools as JSON verbs
  2. Tool definition anatomy
  3. Chat output vs tool args
  4. Zod validate-before-execute
  5. Path-safe file tool example
  6. Dangerous schema patterns
  7. When not to expose a tool
  8. FAQ

Tools are JSON verbs, not prose skills

Search interest around mcp tool json schema, agent tool schema, and mcp tool definition json tracks a real shift: teams package agent behavior as tools with machine-readable contracts. Marketing calls them skills, playbooks, or plugins. Under the hood you still need a name the model can pick, a description that states side effects, and an inputSchema the host can enforce.

Chat structured output answers a user. Tool calls act on systems. That is why a soft “please pass valid JSON” instruction is even less adequate here than for extraction jobs. A bad extraction pollutes a form. A bad tool call can delete a repo path or charge an API.

If you are still designing the chat-side contract, start with the companion guide LLM JSON structured output. This article assumes you already accept that models are not a trust boundary, and focuses on the tool host.

Anatomy of a tool definition

MCP-style listings usually expose three fields the model sees and one the host owns. The model sees name, description, and input schema. The host owns the executor function and the validation step between proposed args and that function.

{
  "name": "create_issue",
  "description": "Create a tracked issue on the project board. Does not assign owners.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["title", "priority"],
    "properties": {
      "title": { "type": "string", "minLength": 3, "maxLength": 120 },
      "priority": { "type": "string", "enum": ["low", "medium", "high"] },
      "labels": {
        "type": "array",
        "items": { "type": "string", "maxLength": 32 },
        "maxItems": 8
      }
    }
  }
}

Write the description for the model: what the tool does, what it refuses, and which args matter. Write the schema for safety: closed enums, max lengths, and additionalProperties: false. Keep tools small. A “do everything in the product” tool reintroduces free-form chaos under a fancy name.

Generate a first-pass schema from a realistic sample with the JSON Schema Generator, then tighten by hand. Sample-driven schemas are usually too wide—they allow every field as optional and every string unbounded.

Chat structured output vs tool call arguments

DimensionChat structured outputTool call arguments
Primary consumerYour app UI or database rowTool executor / side-effect runner
Failure costWrong display or bad recordReal API, file, or money impact
Schema sizeCan be larger extraction shapesPrefer small verb + few fields
Default on errorRetry or show form errorsFail closed; do not execute
Who authors itProduct + API contractPlatform/security + product

A practical split used by strong agent stacks: free-form or structured chat for planning, then one or more narrow tools for actions. Two small schemas beat one mega-schema that mixes intent labels with destructive parameters.

Validate with Zod before execute

Expose JSON Schema to the model (or to an MCP client). Keep a TypeScript Zod schema as the host boundary. You can draft Zod from a sample with JSON to Zod, then align enums and limits with the published inputSchema.

import { z } from "zod";

const CreateIssueArgs = z.object({
  title: z.string().min(3).max(120),
  priority: z.enum(["low", "medium", "high"]),
  labels: z.array(z.string().max(32)).max(8).optional(),
});

export type CreateIssueArgs = z.infer<typeof CreateIssueArgs>;

/** Host entrypoint: never call the API with raw model JSON */
export async function handleCreateIssueTool(rawArgs: unknown) {
  const parsed = CreateIssueArgs.safeParse(rawArgs);
  if (!parsed.success) {
    return {
      ok: false as const,
      error: "invalid_tool_args",
      issues: parsed.error.issues.map((i) => ({
        path: i.path.join("."),
        message: i.message,
      })),
    };
  }

  // only after validation:
  // await issuesApi.create(parsed.data);
  return { ok: true as const, args: parsed.data };
}

Return structured tool errors the model can read. Do not throw stack traces into the model context. Do not “helpfully” fill missing required fields with defaults that change product meaning—especially not for priority, amount, or destination path.

Expert preference: fail closed

For tools, prefer hard rejection over repair. Repairing trailing commas while debugging is fine. Inventing a missing path or command because the model “almost got it” is how agent demos become security reviews. Use JSON Repair only offline when inspecting broken samples.

Example: path-constrained file write

File tools are the classic footgun. If you accept an arbitrary string path, you inherit path traversal. Constrain to a workspace-relative pattern, ban .., and cap size.

{
  "name": "write_workspace_file",
  "description": "Write a UTF-8 text file under /workspace only. Never use absolute paths.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["relativePath", "content"],
    "properties": {
      "relativePath": {
        "type": "string",
        "minLength": 1,
        "maxLength": 200,
        "pattern": "^(?!.*\\.\\./)[A-Za-z0-9._/-]+$"
      },
      "content": {
        "type": "string",
        "maxLength": 50000
      }
    }
  }
}
import path from "node:path";
import { z } from "zod";

const WriteArgs = z.object({
  relativePath: z
    .string()
    .min(1)
    .max(200)
    .regex(/^(?!.*\.\.\/)[A-Za-z0-9._/-]+$/),
  content: z.string().max(50_000),
});

const WORKSPACE = "/workspace";

export function resolveSafePath(relativePath: string) {
  const resolved = path.resolve(WORKSPACE, relativePath);
  if (!resolved.startsWith(WORKSPACE + path.sep) && resolved !== WORKSPACE) {
    throw new Error("path escapes workspace");
  }
  return resolved;
}

export function prepareWrite(raw: unknown) {
  const args = WriteArgs.parse(raw);
  const absolute = resolveSafePath(args.relativePath);
  return { absolute, content: args.content };
}

Schema and path resolution both matter. Regex alone is not a security boundary on every OS, but it filters obvious garbage before you even open a file handle. Always resolve and prefix-check on the host.

Dangerous schema patterns

PatternWhat goes wrongSafer default
Unbounded path or commandpath traversal, shell injectionAllowlists, enums, path patterns, no raw shell
additionalProperties allowedhallucinated flags change behavioradditionalProperties: false everywhere
No maxItems / maxLengthtoken spam, memory pressureHard caps on strings and arrays
Overlapping tool namesmodel picks the wrong verbDistinct names and sharper descriptions
Secrets in argslogs and traces leak credentialsNever accept API keys as free-form tool args

After you edit a schema, re-check samples in the JSON Schema Validator. Include adversarial samples: extra properties, empty strings, path traversal, and oversized arrays. If your validator cannot express a host rule (symlink escapes, authz), put that rule in code next to the schema, not only in the prompt.

When not to expose a tool

  • Unrestricted shell or eval with user- or model-controlled strings.
  • Secret material writes (tokens, private keys) without a dedicated secrets vault flow.
  • Irreversible deletes or production deploys without a human confirmation step.
  • Tools that duplicate each other with slightly different names—merge or delete one.
  • Schemas larger than the model can reliably fill; split the workflow instead.

Skill packs and agent configs should list tools explicitly. A long natural-language skill file without schemas is not a substitute for inputSchema. Write the JSON contracts first, then the prose that teaches the model when to call them.

Host checklist

  1. 1. Publish name, description, and closed inputSchema to the model or MCP client.
  2. 2. Keep an equivalent runtime schema (Zod/Pydantic/AJV) on the host.
  3. 3. Validate every call; fail closed on error.
  4. 4. Enforce non-schema rules (authz, path roots) in code after parse.
  5. 5. Log redacted args and validation issues for offline tuning.
  6. 6. Prefer many narrow tools over one god-tool.

Frequently asked questions

What is an MCP tool JSON schema?

It is the argument schema attached to a tool definition so clients and models know which JSON fields are legal. Hosts must still validate before side effects.

Is this the same as OpenAI function calling?

The wire formats differ, but the discipline is the same: named tools, JSON parameters, host execution. Map concepts carefully; do not assume every keyword is portable across providers.

Do schemas replace system prompts?

No. Prompts steer when to call a tool. Schemas constrain what a call may contain. You need both.

Should I auto-repair invalid tool JSON?

Prefer rejection and a short error the model can fix. Mechanical repair of syntax is a debugging aid, not a production trust layer for tool arguments.

Related tutorials

Back to TutorialsWritten by Zhisan, July 2026