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

> Discover how Open-SEO validates and structures MCP tool output schemas using Zod. Learn about runtime validation, error logging, and client error handling.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-08-10

---

**Open‑SEO validates MCP tool outputs at runtime using Zod schemas defined in [`src/server/mcp/output-schemas.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/output-schemas.ts), with the `instrumentMcpToolHandler` wrapper in [`src/server/mcp/instrumentation.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

```ts
// 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:

```ts
// 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:

```ts
// 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`](https://github.com/every-app/open-seo/blob/main/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:

```ts
// 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:

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

```ts
// 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:

```ts
// 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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/output-schemas.ts) | Central Zod definitions; houses `looseObjectOutputSchema`, tool‑specific schemas, and `mcpMetaOutputSchema` |
| [`src/server/mcp/instrumentation.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/output-schemas.ts), with `looseObjectOutputSchema` as the permissive base for SDK compatibility.
- **Validation mechanism**: `instrumentMcpToolHandler` in [`src/server/mcp/instrumentation.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.