Fix MCP output validation and error reporting
This commit is contained in:
parent
f1232554f1
commit
d339f0c3d9
@ -16,6 +16,7 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
|||||||
<img width="1385" height="794" alt="Image" src="https://github.com/user-attachments/assets/fd208249-44ea-4849-bb4b-5fc896aeab73" />
|
<img width="1385" height="794" alt="Image" src="https://github.com/user-attachments/assets/fd208249-44ea-4849-bb4b-5fc896aeab73" />
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Main SEO Workflows](#main-seo-workflows)
|
- [Main SEO Workflows](#main-seo-workflows)
|
||||||
- [OpenSEO MCP](#openseo-mcp)
|
- [OpenSEO MCP](#openseo-mcp)
|
||||||
- [OpenSEO Agent Skills](#openseo-agent-skills)
|
- [OpenSEO Agent Skills](#openseo-agent-skills)
|
||||||
@ -32,6 +33,7 @@ OpenSEO is an SEO tool for _the people_. If tools like Semrush or Ahrefs are too
|
|||||||
- [SEO API Cost Reference](#seo-api-cost-reference)
|
- [SEO API Cost Reference](#seo-api-cost-reference)
|
||||||
|
|
||||||
## Hosted Version
|
## Hosted Version
|
||||||
|
|
||||||
If you not interested in self hosting, or just want to support the project, we also have a hosted version:
|
If you not interested in self hosting, or just want to support the project, we also have a hosted version:
|
||||||
|
|
||||||
[openseo.so](https://openseo.so)
|
[openseo.so](https://openseo.so)
|
||||||
|
|||||||
88
src/server/mcp/instrumentation.test.ts
Normal file
88
src/server/mcp/instrumentation.test.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
captureServerError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// waitUntil runs the capture promise inline so assertions see the call.
|
||||||
|
vi.mock("cloudflare:workers", () => ({
|
||||||
|
waitUntil: (promise: Promise<unknown>) => void promise,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/lib/posthog", () => ({
|
||||||
|
captureServerError: mocks.captureServerError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toolExtra: ToolExtra = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
requestId: 1,
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
sendRequest: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputSchema = { items: z.array(z.object({}).passthrough()) };
|
||||||
|
|
||||||
|
function okResult(structuredContent: Record<string, unknown>): CallToolResult {
|
||||||
|
return { content: [{ type: "text", text: "ok" }], structuredContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("instrumentMcpToolHandler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.captureServerError.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a valid result through without reporting", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||||
|
okResult({ items: [{ domain: "example.com" }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await wrapped({}, toolExtra);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toEqual({
|
||||||
|
items: [{ domain: "example.com" }],
|
||||||
|
});
|
||||||
|
expect(mocks.captureServerError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an output schema mismatch the SDK would silently reject", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||||
|
okResult({ items: "not-an-array" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await wrapped({}, toolExtra);
|
||||||
|
|
||||||
|
expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.captureServerError.mock.calls[0][1]).toMatchObject({
|
||||||
|
errorCode: "MCP_OUTPUT_VALIDATION",
|
||||||
|
tool: "demo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports and rethrows a reportable handler error", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const boom = new Error("upstream exploded");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
|
||||||
|
throw boom;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(wrapped({}, toolExtra)).rejects.toThrow("upstream exploded");
|
||||||
|
expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.captureServerError.mock.calls[0][0]).toBe(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows expected errors without reporting them", async () => {
|
||||||
|
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||||
|
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
|
||||||
|
throw new AppError("NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(wrapped({}, toolExtra)).rejects.toThrow("NOT_FOUND");
|
||||||
|
expect(mocks.captureServerError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
src/server/mcp/instrumentation.ts
Normal file
85
src/server/mcp/instrumentation.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { waitUntil } from "cloudflare:workers";
|
||||||
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import {
|
||||||
|
getParseErrorMessage,
|
||||||
|
normalizeObjectSchema,
|
||||||
|
safeParseAsync,
|
||||||
|
type AnySchema,
|
||||||
|
type ZodRawShapeCompat,
|
||||||
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||||
|
import { asAppError } from "@/server/lib/errors";
|
||||||
|
import { captureServerError } from "@/server/lib/posthog";
|
||||||
|
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
|
|
||||||
|
type ToolHandler<TArgs> = (
|
||||||
|
args: TArgs,
|
||||||
|
extra: ToolExtra,
|
||||||
|
) => CallToolResult | Promise<CallToolResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an MCP tool handler so failures reach PostHog. Unlike TanStack server
|
||||||
|
* functions (covered by errorHandlingMiddleware), the MCP route has no error
|
||||||
|
* middleware, so tool failures are otherwise invisible in error reporting.
|
||||||
|
*
|
||||||
|
* This captures two classes of failure:
|
||||||
|
* - Exceptions thrown by the handler (DataForSEO outages, auth failures, …),
|
||||||
|
* gated by shouldCaptureAppErrorCode to keep expected errors out of PostHog.
|
||||||
|
* - Output-schema validation failures. The SDK validates structuredContent
|
||||||
|
* against the output schema *after* the handler returns and converts a
|
||||||
|
* failure into a -32602 JSON-RPC error it never rethrows, so we re-run the
|
||||||
|
* same validation (via the SDK's own helpers) to surface the mismatch
|
||||||
|
* instead of shipping it silently.
|
||||||
|
*/
|
||||||
|
export function instrumentMcpToolHandler<TArgs>(
|
||||||
|
toolName: string,
|
||||||
|
outputSchema: AnySchema | ZodRawShapeCompat | undefined,
|
||||||
|
handler: ToolHandler<TArgs>,
|
||||||
|
): (args: TArgs, extra: ToolExtra) => Promise<CallToolResult> {
|
||||||
|
const normalizedOutputSchema = normalizeObjectSchema(outputSchema);
|
||||||
|
|
||||||
|
return async (args, extra) => {
|
||||||
|
try {
|
||||||
|
const result = await handler(args, extra);
|
||||||
|
if (
|
||||||
|
normalizedOutputSchema &&
|
||||||
|
!result.isError &&
|
||||||
|
result.structuredContent
|
||||||
|
) {
|
||||||
|
const validation = await safeParseAsync(
|
||||||
|
normalizedOutputSchema,
|
||||||
|
result.structuredContent,
|
||||||
|
);
|
||||||
|
if (!validation.success) {
|
||||||
|
// getParseErrorMessage reports type-level mismatches (expected vs
|
||||||
|
// received *types*), so it carries no row data. Keep it that way:
|
||||||
|
// output schemas must not gain value-echoing refinements (enums on
|
||||||
|
// user data, etc.) that would surface response values in PostHog.
|
||||||
|
waitUntil(
|
||||||
|
captureServerError(
|
||||||
|
new Error(`MCP output validation failed for ${toolName}`),
|
||||||
|
{
|
||||||
|
errorCode: "MCP_OUTPUT_VALIDATION",
|
||||||
|
tool: toolName,
|
||||||
|
issues: getParseErrorMessage(validation.error).slice(0, 500),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const appError = asAppError(error);
|
||||||
|
if (shouldCaptureAppErrorCode(appError?.code)) {
|
||||||
|
console.error(`mcp.tool error (${toolName}):`, error);
|
||||||
|
waitUntil(
|
||||||
|
captureServerError(error, {
|
||||||
|
errorCode: appError?.code ?? "INTERNAL_ERROR",
|
||||||
|
tool: toolName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,7 +11,13 @@ const mcpMetaOutputSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
export const looseObjectOutputSchema = z.record(z.string(), z.unknown());
|
// Tools that pass DataForSEO rows straight through to structuredContent hand the
|
||||||
|
// MCP SDK typed class instances (e.g. DataforseoLabsSerpCompetitorsLiveItem), not
|
||||||
|
// plain objects. Zod 4's z.record() requires a plain-object prototype and rejects
|
||||||
|
// class instances ("expected record, received <ClassName>"), which the SDK surfaces
|
||||||
|
// as a -32602 output validation error. A loose object schema accepts any object
|
||||||
|
// shape, so it validates both plain rows and typed instances.
|
||||||
|
export const looseObjectOutputSchema = z.object({}).passthrough();
|
||||||
|
|
||||||
export const optionalMetaOutputSchema = {
|
export const optionalMetaOutputSchema = {
|
||||||
meta: mcpMetaOutputSchema.optional(),
|
meta: mcpMetaOutputSchema.optional(),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
|
||||||
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
||||||
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
||||||
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
||||||
@ -22,91 +23,172 @@ import {
|
|||||||
} from "@/server/mcp/tools/search-console-tools";
|
} from "@/server/mcp/tools/search-console-tools";
|
||||||
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
||||||
|
|
||||||
|
// Each handler is wrapped with instrumentMcpToolHandler so failures reach
|
||||||
|
// PostHog — the MCP route has no error middleware of its own. Tools are
|
||||||
|
// registered one explicit call at a time (not via a loop/helper) so each one's
|
||||||
|
// input/output schema types stay concrete, which the SDK's registerTool
|
||||||
|
// generics require to type the handler callback.
|
||||||
export function registerOpenSeoMcpTools(server: McpServer) {
|
export function registerOpenSeoMcpTools(server: McpServer) {
|
||||||
server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler);
|
server.registerTool(
|
||||||
|
whoamiTool.name,
|
||||||
|
whoamiTool.config,
|
||||||
|
instrumentMcpToolHandler(
|
||||||
|
whoamiTool.name,
|
||||||
|
whoamiTool.config.outputSchema,
|
||||||
|
whoamiTool.handler,
|
||||||
|
),
|
||||||
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
listProjectsTool.name,
|
listProjectsTool.name,
|
||||||
listProjectsTool.config,
|
listProjectsTool.config,
|
||||||
listProjectsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
listProjectsTool.name,
|
||||||
|
listProjectsTool.config.outputSchema,
|
||||||
|
listProjectsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
listSavedKeywordsTool.name,
|
listSavedKeywordsTool.name,
|
||||||
listSavedKeywordsTool.config,
|
listSavedKeywordsTool.config,
|
||||||
listSavedKeywordsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
listSavedKeywordsTool.name,
|
||||||
|
listSavedKeywordsTool.config.outputSchema,
|
||||||
|
listSavedKeywordsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
researchKeywordsTool.name,
|
researchKeywordsTool.name,
|
||||||
researchKeywordsTool.config,
|
researchKeywordsTool.config,
|
||||||
researchKeywordsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
researchKeywordsTool.name,
|
||||||
|
researchKeywordsTool.config.outputSchema,
|
||||||
|
researchKeywordsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
saveKeywordsTool.name,
|
saveKeywordsTool.name,
|
||||||
saveKeywordsTool.config,
|
saveKeywordsTool.config,
|
||||||
saveKeywordsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
saveKeywordsTool.name,
|
||||||
|
saveKeywordsTool.config.outputSchema,
|
||||||
|
saveKeywordsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getDomainOverviewTool.name,
|
getDomainOverviewTool.name,
|
||||||
getDomainOverviewTool.config,
|
getDomainOverviewTool.config,
|
||||||
getDomainOverviewTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getDomainOverviewTool.name,
|
||||||
|
getDomainOverviewTool.config.outputSchema,
|
||||||
|
getDomainOverviewTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getDomainKeywordSuggestionsTool.name,
|
getDomainKeywordSuggestionsTool.name,
|
||||||
getDomainKeywordSuggestionsTool.config,
|
getDomainKeywordSuggestionsTool.config,
|
||||||
getDomainKeywordSuggestionsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getDomainKeywordSuggestionsTool.name,
|
||||||
|
getDomainKeywordSuggestionsTool.config.outputSchema,
|
||||||
|
getDomainKeywordSuggestionsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getBacklinksOverviewTool.name,
|
getBacklinksOverviewTool.name,
|
||||||
getBacklinksOverviewTool.config,
|
getBacklinksOverviewTool.config,
|
||||||
getBacklinksOverviewTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getBacklinksOverviewTool.name,
|
||||||
|
getBacklinksOverviewTool.config.outputSchema,
|
||||||
|
getBacklinksOverviewTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getSerpResultsTool.name,
|
getSerpResultsTool.name,
|
||||||
getSerpResultsTool.config,
|
getSerpResultsTool.config,
|
||||||
getSerpResultsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getSerpResultsTool.name,
|
||||||
|
getSerpResultsTool.config.outputSchema,
|
||||||
|
getSerpResultsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getRankTrackerTool.name,
|
getRankTrackerTool.name,
|
||||||
getRankTrackerTool.config,
|
getRankTrackerTool.config,
|
||||||
getRankTrackerTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getRankTrackerTool.name,
|
||||||
|
getRankTrackerTool.config.outputSchema,
|
||||||
|
getRankTrackerTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getRankedKeywordsTool.name,
|
getRankedKeywordsTool.name,
|
||||||
getRankedKeywordsTool.config,
|
getRankedKeywordsTool.config,
|
||||||
getRankedKeywordsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getRankedKeywordsTool.name,
|
||||||
|
getRankedKeywordsTool.config.outputSchema,
|
||||||
|
getRankedKeywordsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
findSerpCompetitorsTool.name,
|
findSerpCompetitorsTool.name,
|
||||||
findSerpCompetitorsTool.config,
|
findSerpCompetitorsTool.config,
|
||||||
findSerpCompetitorsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
findSerpCompetitorsTool.name,
|
||||||
|
findSerpCompetitorsTool.config.outputSchema,
|
||||||
|
findSerpCompetitorsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
searchLocalBusinessesTool.name,
|
searchLocalBusinessesTool.name,
|
||||||
searchLocalBusinessesTool.config,
|
searchLocalBusinessesTool.config,
|
||||||
searchLocalBusinessesTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
searchLocalBusinessesTool.name,
|
||||||
|
searchLocalBusinessesTool.config.outputSchema,
|
||||||
|
searchLocalBusinessesTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getLocalSerpResultsTool.name,
|
getLocalSerpResultsTool.name,
|
||||||
getLocalSerpResultsTool.config,
|
getLocalSerpResultsTool.config,
|
||||||
getLocalSerpResultsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getLocalSerpResultsTool.name,
|
||||||
|
getLocalSerpResultsTool.config.outputSchema,
|
||||||
|
getLocalSerpResultsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getGoogleBusinessQuestionsTool.name,
|
getGoogleBusinessQuestionsTool.name,
|
||||||
getGoogleBusinessQuestionsTool.config,
|
getGoogleBusinessQuestionsTool.config,
|
||||||
getGoogleBusinessQuestionsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getGoogleBusinessQuestionsTool.name,
|
||||||
|
getGoogleBusinessQuestionsTool.config.outputSchema,
|
||||||
|
getGoogleBusinessQuestionsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getKeywordMetricsTool.name,
|
getKeywordMetricsTool.name,
|
||||||
getKeywordMetricsTool.config,
|
getKeywordMetricsTool.config,
|
||||||
getKeywordMetricsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getKeywordMetricsTool.name,
|
||||||
|
getKeywordMetricsTool.config.outputSchema,
|
||||||
|
getKeywordMetricsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getSearchConsolePerformanceTool.name,
|
getSearchConsolePerformanceTool.name,
|
||||||
getSearchConsolePerformanceTool.config,
|
getSearchConsolePerformanceTool.config,
|
||||||
getSearchConsolePerformanceTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
getSearchConsolePerformanceTool.name,
|
||||||
|
getSearchConsolePerformanceTool.config.outputSchema,
|
||||||
|
getSearchConsolePerformanceTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
inspectUrlsTool.name,
|
inspectUrlsTool.name,
|
||||||
inspectUrlsTool.config,
|
inspectUrlsTool.config,
|
||||||
inspectUrlsTool.handler,
|
instrumentMcpToolHandler(
|
||||||
|
inspectUrlsTool.name,
|
||||||
|
inspectUrlsTool.config.outputSchema,
|
||||||
|
inspectUrlsTool.handler,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/server/mcp/tools/output-schema-validation.test.ts
Normal file
53
src/server/mcp/tools/output-schema-validation.test.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
normalizeObjectSchema,
|
||||||
|
safeParseAsync,
|
||||||
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({
|
||||||
|
env: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A class instance reproduces what the DataForSEO SDK hands the tools: an
|
||||||
|
// object whose prototype is not Object.prototype (e.g.
|
||||||
|
// DataforseoLabsSerpCompetitorsLiveItem). Zod 4's z.record() rejects those
|
||||||
|
// ("expected record, received <ClassName>"), so a record-based output schema
|
||||||
|
// makes the MCP server fail these passthrough tools with a -32602 output
|
||||||
|
// validation error even though the API call succeeded.
|
||||||
|
class ProviderRow {
|
||||||
|
constructor(
|
||||||
|
public domain: string,
|
||||||
|
public rank_absolute: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DataForSEO research tool output schemas", () => {
|
||||||
|
// Every tool that streams provider rows straight to structuredContent.
|
||||||
|
it.each([
|
||||||
|
["find_serp_competitors", "competitors"],
|
||||||
|
["get_local_serp_results", "results"],
|
||||||
|
["search_local_businesses", "businesses"],
|
||||||
|
["get_google_business_questions", "questions"],
|
||||||
|
["get_ranked_keywords", "keywords"],
|
||||||
|
])(
|
||||||
|
"%s accepts typed (non-plain-object) provider rows",
|
||||||
|
async (toolName, field) => {
|
||||||
|
const tools = await import("./dataforseo-research-tools");
|
||||||
|
const tool = Object.values(tools).find((t) => t.name === toolName);
|
||||||
|
if (!tool) throw new Error(`tool ${toolName} not found`);
|
||||||
|
|
||||||
|
const schema = normalizeObjectSchema(tool.config.outputSchema);
|
||||||
|
if (!schema) throw new Error("output schema did not normalize");
|
||||||
|
|
||||||
|
// Mirror the MCP server: validate structuredContent against the tool's
|
||||||
|
// own output schema. Extra keys (e.g. get_ranked_keywords' totalCount)
|
||||||
|
// are allowed by the passthrough schemas, so one payload covers all.
|
||||||
|
const result = await safeParseAsync(schema, {
|
||||||
|
[field]: [new ProviderRow("example.com", 1)],
|
||||||
|
totalCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user