How MCP Tool Output Schemas Are Validated and Structured: A Deep Dive into Open‑SEO's Zod‑Based Architecture

Open‑SEO validates MCP tool outputs at runtime using Zod schemas defined in src/server/mcp/output-schemas.ts, with the instrumentMcpToolHandler wrapper in src/server/mcp/instrumentation.ts performing safeParseAsync validation and logging failures to PostHog while returning JSON‑RPC -32602 errors to clients.

The Model‑Context‑Protocol (MCP) layer in Open‑SEO enforces strict output contracts for every tool using Zod, a TypeScript‑first schema validation library. This article examines how MCP tool output schemas are structured, where validation occurs, and how mismatches are handled for observability—directly from the every-app/open-seo source code.


Where MCP Output Schemas Are Defined

All schema definitions live in src/server/mcp/output-schemas.ts. This file serves as the single source of truth for output shapes across the entire MCP tool surface.

The Base looseObjectOutputSchema

At line 33, the file exports a permissive base schema:

// src/server/mcp/output-schemas.ts#L33-L34
export const looseObjectOutputSchema = z.object({}).passthrough();

This schema uses z.object({}).passthrough() to accept any class instance or plain object without strict key checking. It exists because several tools return typed class instances from the DataForSEO SDK (e.g., DataforseoLabsSerpCompetitorsLiveItem), and Zod's z.record() rejects non‑plain objects.

Concrete Tool Schemas

Tool‑specific schemas extend this foundation. The backlinksProfileOutputSchema (lines 35‑44) demonstrates the pattern:

// src/server/mcp/output-schemas.ts#L35-L44
export const backlinksProfileOutputSchema = z
  .object({
    rows: z.array(looseObjectOutputSchema), // accepts any row shape
    totalCount: z.number().nullable(),
    hasMore: z.boolean(),
    page: z.number(),
    pageSize: z.number(),
    fetchedAt: z.string().optional(),
  })
  .passthrough();

Key design decisions here:

  • rows uses looseObjectOutputSchema to remain flexible per‑tool
  • totalCount uses .nullable() for API‑driven nullability
  • .passthrough() preserves extra fields returned by downstream APIs

Optional Meta‑Information Schema

Lines 16‑25 provide a reusable meta schema:

// src/server/mcp/output-schemas.ts#L16-L25
export const mcpMetaOutputSchema = z.object({
  quotaRemaining: z.number().optional(),
  executionTimeMs: z.number().optional(),
  source: z.string().optional(),
});

export const optionalMetaOutputSchema = mcpMetaOutputSchema.optional();

Tools compose this via optionalMetaOutputSchema to include telemetry without polluting core output shapes.


Runtime Validation in the Instrumentation Layer

Schema enforcement happens in src/server/mcp/instrumentation.ts through the instrumentMcpToolHandler wrapper. This function wraps every tool handler to intercept, validate, and observe outputs.

The Validation Flow

When a handler returns, the wrapper (lines 90‑108) executes:

// src/server/mcp/instrumentation.ts#L90-L108 (conceptual extraction)
let outputValidationFailed = false;
if (outputSchema && !result.isError && result.structuredContent) {
  const validation = await outputSchema.safeParseAsync(
    result.structuredContent,
  );
  if (!validation.success) {
    outputValidationFailed = true;
    // Report to observability platform
    captureServerError(
      new Error(`MCP output validation failed for ${toolName}`),
      {
        errorCode: "MCP_OUTPUT_VALIDATION",
        issues: formatValidationIssues(validation.error),
      },
    );
  }
}

The safeParseAsync method is chosen over parseAsync to allow graceful error handling without throwing. If validation fails:

  1. outputValidationFailed flags the call
  2. PostHog receives a structured event with formatted Zod issues
  3. The MCP SDK propagates a JSON‑RPC -32602 (Invalid params) error to the client

Telemetry Regardless of Outcome

Lines 81‑99 record metrics uniformly—whether the failure was an exception or a schema mismatch:

captureMcpToolCall(toolName, context, {
  success: !outputValidationFailed && !result.isError,
  durationMs: performance.now() - startedAt,
  rowCount: result.structuredContent?.rows?.length,
  quotaRemaining: result.structuredContent?.meta?.quotaRemaining,
});

This design ensures validation failures are observable without breaking analytics continuity.


Wiring Schemas to Tool Handlers

Individual tools in src/server/mcp/tools/ declare their output schemas and pass them to the instrumentation wrapper. Here's the complete wiring for the backlinks profile tool:

// Simplified from src/server/mcp/tools/get-backlinks-profile.ts
import {
  backlinksProfileOutputSchema,
  optionalMetaOutputSchema,
} from "../output-schemas";
import { instrumentMcpToolHandler } from "../instrumentation";

export const getBacklinksProfileTool = {
  name: "get_backlinks_profile",
  description: "Retrieve backlink profile data for a domain",
  // Input schema (omitted for brevity)
  // Output schema attached for validation
  outputSchema: backlinksProfileOutputSchema,
  
  handler: instrumentMcpToolHandler(
    "get_backlinks_profile",
    backlinksProfileOutputSchema,
    async (args, ctx) => {
      const data = await fetchBacklinksFromDataForSEO(args.domain);
      
      return {
        structuredContent: {
          rows: data.items,           // DataForSEO class instances
          totalCount: data.total,
          hasMore: data.has_more,
          page: data.page,
          pageSize: data.items_per_page,
          fetchedAt: new Date().toISOString(),
        },
      };
    },
  ),
};

The outputSchema property serves two purposes: it documents the contract for MCP clients and enables runtime validation through the wrapper.


Why "Loose" Schemas Enable SDK Compatibility

The looseObjectOutputSchema exists to solve a specific friction point with the DataForSEO SDK. Consider this scenario:

// DataForSEO returns class instances, not plain objects
const item = new DataforseoLabsSerpCompetitorsLiveItem({
  se_type: "organic",
  competitor_metrics: { ... },
});

Zod's z.record(z.any()) rejects this because instanceof Object differs from a plain object literal. The passthrough() approach instead:

  • Accepts any object‑like value (class instance or literal)
  • Preserves all enumerable properties
  • Avoids deep validation that would require SDK type duplication

This trade‑off prioritizes integration resilience over strict field‑level validation for nested DataForSEO structures.


Key Files and Their Responsibilities

File Role in Schema Validation
src/server/mcp/output-schemas.ts Central Zod definitions; houses looseObjectOutputSchema, tool‑specific schemas, and mcpMetaOutputSchema
src/server/mcp/instrumentation.ts Wraps handlers; executes safeParseAsync validation; logs MCP_OUTPUT_VALIDATION events to PostHog
src/server/mcp/tools/*.ts Individual tool implementations that declare outputSchema and compose instrumentMcpToolHandler
src/server/mcp/context.ts Defines MCP route constants and auth context used during validation and telemetry

Summary

  • Schema location: All MCP output schemas live in src/server/mcp/output-schemas.ts, with looseObjectOutputSchema as the permissive base for SDK compatibility.
  • Validation mechanism: instrumentMcpToolHandler in src/server/mcp/instrumentation.ts runs outputSchema.safeParseAsync() against result.structuredContent.
  • Failure handling: Schema mismatches trigger outputValidationFailed = true, PostHog logging with formatted Zod issues, and JSON‑RPC -32602 client errors.
  • Observability: captureMcpToolCall records success/failure metrics uniformly, enabling validation failure analysis without telemetry gaps.
  • Tool wiring: Tools declare outputSchema explicitly and pass it to the instrumentation wrapper for automatic enforcement.

Frequently Asked Questions

How does Open‑SEO handle DataForSEO SDK class instances in MCP outputs?

Open‑SEO uses looseObjectOutputSchema (z.object({}).passthrough()) defined at line 33 of src/server/mcp/output-schemas.ts. This permissive schema accepts both plain objects and class instances from the DataForSEO SDK, which Zod's stricter z.record() would reject. Tool‑specific schemas like backlinksProfileOutputSchema wrap arrays of this loose type in their rows fields.

What error code does an MCP client receive when output validation fails?

The MCP SDK automatically returns a JSON‑RPC -32602 error (Invalid params) to the client. This occurs after instrumentMcpToolHandler detects validation failure, sets outputValidationFailed = true, and logs the detailed Zod issues to PostHog under the MCP_OUTPUT_VALIDATION error code.

Can MCP tools include optional metadata in their outputs?

Yes. Open‑SEO provides mcpMetaOutputSchema and optionalMetaOutputSchema (lines 16‑25 in src/server/mcp/output-schemas.ts) for attaching telemetry like quotaRemaining, executionTimeMs, and source. Tools compose this via optionalMetaOutputSchema without modifying their core output shapes.

Where is the safeParseAsync validation actually executed?

The validation runs inside instrumentMcpToolHandler in src/server/mcp/instrumentation.ts, specifically around lines 90‑108. The wrapper checks for outputSchema existence, validates result.structuredContent, and handles both success and failure paths before returning to the MCP server.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →