← Back to DevBytes

Structured Output Validation with MCP (Model Context Protocol): Complete Guide

Structured Output Validation with MCP (Model Context Protocol): Complete Guide

The Model Context Protocol (MCP) has rapidly become the standard way to connect AI models to external tools, data sources, and services. But as agents grow more autonomous, one problem keeps surfacing: models return data in unpredictable shapes. A tool that should return a list of user objects sometimes returns a single object, a string, or even a malformed payload. Structured output validation is the discipline of guaranteeing that what flows between models and tools conforms to a known contract — and in MCP, this is both a design principle and a set of concrete APIs you can use today.

This guide walks through what structured output validation means in the context of MCP, why it matters for production agents, how to implement it across the server and client sides, and the best practices that separate toy demos from reliable systems.

What Is Structured Output Validation in MCP?

MCP is a JSON-RPC 2.0 based protocol that lets a host application (like Claude Desktop, an IDE, or a custom agent runtime) communicate with servers that expose tools, resources, and prompts. Every tool in MCP is defined with an input schema — a JSON Schema object that describes the arguments the tool accepts. When a model decides to call a tool, the client validates the arguments against that schema before the request ever reaches your server.

Structured output validation extends this idea to the results that tools return. While the MCP specification does not force servers to declare output schemas for every tool, the protocol provides mechanisms — content types, structured content, and schema references — that let you describe and enforce the shape of what comes back. Validation therefore happens at three layers:

Together, these layers turn MCP from a "best effort" RPC mechanism into a typed, contract-driven system where both sides know exactly what to expect.

Why It Matters

Without validation, agents fail in subtle and expensive ways. A model might receive a tool result it cannot parse, hallucinate fields that don't exist, or pass malformed data into the next tool call. In production, these failures manifest as infinite retry loops, broken downstream API calls, or — worse — silent data corruption. Structured validation gives you four concrete benefits:

How to Define Tools with Input Schemas

Every MCP tool is declared with a name, description, and JSON Schema for its inputs. Here is a minimal example using the official TypeScript SDK, where we expose a tool that looks up a user by ID and returns a structured object.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "user-service", version: "1.0.0" });

server.tool(
  "get_user",
  "Fetch a user by their numeric ID. Returns a structured user object.",
  {
    userId: z.number().int().positive().describe("The unique user ID"),
    includeProfile: z.boolean().default(false).describe("Whether to embed profile data"),
  },
  async ({ userId, includeProfile }) => {
    const user = await db.users.findById(userId);
    if (!user) {
      return {
        isError: true,
        content: [{ type: "text", text: `User ${userId} not found` }],
      };
    }
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(user),
        },
      ],
    };
  }
);

Notice that the SDK uses Zod to declare the input shape. Under the hood, the SDK converts the Zod schema into a JSON Schema and exposes it in the tool's inputSchema field. Any client that calls get_user with a non-integer or negative ID will be rejected before your handler runs.

Validating Tool Outputs

The MCP specification lets tools return content as an array of typed items (text, image, audio, embedded resource). For structured data, the convention is to return JSON serialized as a text content block. To validate that output, you should parse and check it against a schema before returning. The cleanest pattern is to define an output schema alongside your input schema and validate in the handler.

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int(),
  email: z.string().email(),
  name: z.string().min(1),
  createdAt: z.string().datetime(),
  profile: z
    .object({
      bio: z.string().nullable(),
      avatarUrl: z.string().url().nullable(),
    })
    .optional(),
});

type User = z.infer<typeof UserSchema>;

server.tool(
  "get_user",
  "Fetch a user by ID.",
  { userId: z.number().int().positive() },
  async ({ userId }) => {
    const raw = await db.users.findById(userId);
    if (!raw) {
      return { isError: true, content: [{ type: "text", text: "not found" }] };
    }

    // Validate the database row against our output contract.
    const result = UserSchema.safeParse(raw);
    if (!result.success) {
      return {
        isError: true,
        content: [
          {
            type: "text",
            text: `Output validation failed: ${result.error.message}`,
          },
        ],
      };
    }

    return {
      content: [{ type: "text", text: JSON.stringify(result.data) }],
    };
  }
);

This pattern guarantees that no matter what the database returns — missing fields, wrong types, nulls where strings are expected — the tool either returns a valid User or an explicit error. The model never sees a half-formed object.

Using Structured Content for Machine-Readable Results

Recent versions of MCP introduce structuredContent, a field on tool results that carries a JSON object directly, alongside an optional outputSchema declared on the tool itself. This is the most robust way to do structured output validation because the schema is part of the tool's public contract, and compliant clients can validate automatically.

server.tool(
  "search_users",
  "Search users by name fragment.",
  {
    query: z.string().min(1),
    limit: z.number().int().positive().max(100).default(10),
  },
  {
    // Output schema declared as part of the tool definition.
    outputSchema: z.object({
      results: z.array(UserSchema),
      total: z.number().int(),
      hasMore: z.boolean(),
    }),
  },
  async ({ query, limit }) => {
    const rows = await db.users.search(query, limit + 1);
    const hasMore = rows.length > limit;
    const results = rows.slice(0, limit);

    return {
      content: [
        {
          type: "text",
          text: `Found ${results.length} users matching "${query}".`,
        },
      ],
      structuredContent: {
        results,
        total: results.length,
        hasMore,
      },
    };
  }
);

When outputSchema is declared, the SDK validates structuredContent against it before the response is sent. If validation fails, the server returns an error result instead of leaking malformed data to the client. This is the single most important feature for building reliable MCP-based agents.

Client-Side Validation

Even when a server declares output schemas, defensive clients should validate again. Servers evolve, third-party servers may lie, and network-level proxies can mutate payloads. Here is how a client wraps a tool call with its own validation layer.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";

const client = new Client({ name: "agent", version: "1.0.0" });

async function callToolValidated<T>(
  name: string,
  args: Record<string, unknown>,
  schema: z.ZodType<T>
): Promise<T> {
  const response = await client.callTool({ name, arguments: args });

  if (response.isError) {
    throw new Error(
      response.content
        .map((c) => (c.type === "text" ? c.text : ""))
        .join("\n")
    );
  }

  const payload =
    response.structuredContent ??
    parseFirstTextBlock(response.content);

  const parsed = schema.safeParse(payload);
  if (!parsed.success) {
    throw new Error(
      `Tool "${name}" returned invalid data: ${parsed.error.message}`
    );
  }
  return parsed.data;
}

function parseFirstTextBlock(content: unknown): unknown {
  if (!Array.isArray(content)) return null;
  const text = content.find((c) => c?.type === "text");
  if (!text?.text) return null;
  try {
    return JSON.parse(text.text);
  } catch {
    return text.text;
  }
}

With this helper, every tool call in your agent goes through a single validated entry point. If a server ever changes its output shape, you get a typed error at the call site instead of a mysterious downstream failure.

Validating Multi-Step Agent Flows

In real agents, the output of one tool often feeds the input of another. Validation becomes a pipeline concern. A practical approach is to model each step as a function with a typed input and typed output, then compose them with a small runtime that checks contracts at every hop.

const StepInput = z.object({ userId: z.number().int() });
const StepOutput = UserSchema;

type Step = {
  name: string;
  input: z.ZodType;
  output: z.ZodType;
  run: (input: unknown) => Promise<unknown>;
};

async function runPipeline(steps: Step[], seed: unknown) {
  let current = seed;
  for (const step of steps) {
    const inResult = step.input.safeParse(current);
    if (!inResult.success) {
      throw new Error(`Step "${step.name}" input invalid: ${inResult.error.message}`);
    }
    const raw = await step.run(inResult.data);
    const outResult = step.output.safeParse(raw);
    if (!outResult.success) {
      throw new Error(`Step "${step.name}" output invalid: ${outResult.error.message}`);
    }
    current = outResult.data;
  }
  return current;
}

const pipeline: Step[] = [
  {
    name: "fetch_user",
    input: StepInput,
    output: StepOutput,
    run: (i) => callToolValidated("get_user", i, UserSchema),
  },
  {
    name: "enrich_profile",
    input: UserSchema,
    output: UserSchema.extend({ enriched: z.boolean() }),
    run: async (u) => ({ ...u, enriched: true }),
  },
];

const final = await runPipeline(pipeline, { userId: 42 });

This pattern scales: each step is independently testable, contracts are explicit, and a single mismatch stops the pipeline with a precise error message.

Best Practices

Common Pitfalls

One frequent mistake is treating the model as the validator. Models can and do emit arguments that violate a declared schema, especially with edge-case types like enums, dates, or nested objects. The MCP client SDK will reject these, but only if the schema is correctly declared — a missing inputSchema means no validation at all.

Another pitfall is returning large unvalidated blobs (entire database rows, raw API responses) as tool output. These almost always contain fields the model shouldn't see (PII, internal IDs, timestamps in unexpected formats). Validate and project down to exactly the fields your schema declares.

Finally, beware of optional vs nullable confusion. In JSON Schema, optional means the key may be absent, while nullable means the value may be null. In Zod, z.string().optional() and z.string().nullable() are different. Mixing them up produces validation errors that only appear intermittently.

Conclusion

Structured output validation is what turns MCP from a flexible protocol into a dependable foundation for production agents. By declaring input and output schemas on every tool, validating at both the server and client boundaries, and composing validated steps into pipelines, you eliminate an entire class of runtime failures that plague LLM-driven systems. The investment is small — a few schema definitions and a thin validation wrapper — but the payoff is enormous: agents that fail loudly and recoverably instead of silently and catastrophically. Treat schemas as the contract between your model, your tools, and your business logic, and the rest of your agent architecture becomes dramatically easier to reason about.

— Ad —

Google AdSense will appear here after approval

← Back to all articles