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:
rowsuseslooseObjectOutputSchemato remain flexible per‑tooltotalCountuses.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:
outputValidationFailedflags the call- PostHog receives a structured event with formatted Zod issues
- 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, withlooseObjectOutputSchemaas the permissive base for SDK compatibility. - Validation mechanism:
instrumentMcpToolHandlerinsrc/server/mcp/instrumentation.tsrunsoutputSchema.safeParseAsync()againstresult.structuredContent. - Failure handling: Schema mismatches trigger
outputValidationFailed = true, PostHog logging with formatted Zod issues, and JSON‑RPC-32602client errors. - Observability:
captureMcpToolCallrecords success/failure metrics uniformly, enabling validation failure analysis without telemetry gaps. - Tool wiring: Tools declare
outputSchemaexplicitly 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →