From 20e2a07b41e03d0742ff84b2c812d5eb4057d04d Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Tue, 23 Jun 2026 00:05:54 +0300 Subject: [PATCH 1/3] fix(mcp): support draft-2020-12 tool schemas + env-var MCP urls Firecrawl's v2 MCP tools declare their input schema as JSON Schema draft 2020-12. Mastra's tool-input validator can't resolve that meta-schema and rejects every call locally ("no schema with key or ref .../2020-12/schema"), so the tools connect but never actually run. - Override the input schema of the firecrawl scrape/search tools with clean Zod schemas, mutating the Tool instance in place (its execute() validates against this.inputSchema at call time and is lexically bound to the instance, so a spread copy never takes effect). - Apply expandEnvVars to HTTP MCP server urls so the firecrawl key can live in .env (${FIRECRAWL_MCP_URL}) instead of the git-tracked yaml. - Register firecrawl in mcp-servers.yaml, whitelisted to scrape + search. Verified end-to-end: firecrawl_scrape returns real page markdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-servers.yaml | 3 +++ src/agent/mcp-client.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/mcp-servers.yaml b/mcp-servers.yaml index d0927da..a23d600 100644 --- a/mcp-servers.yaml +++ b/mcp-servers.yaml @@ -4,3 +4,6 @@ servers: args: ["-y", "@brave/brave-search-mcp-server"] env: BRAVE_API_KEY: ${BRAVE_API_KEY} + firecrawl: + url: ${FIRECRAWL_MCP_URL} + tools: [firecrawl_scrape, firecrawl_search] diff --git a/src/agent/mcp-client.ts b/src/agent/mcp-client.ts index ecbbf75..f25a491 100644 --- a/src/agent/mcp-client.ts +++ b/src/agent/mcp-client.ts @@ -4,11 +4,32 @@ */ import { MCPClient } from "@mastra/mcp"; import type { Tool } from "@mastra/core/tools"; +import { z } from "zod"; import { expandEnvVars } from "../config.js"; import fs from "node:fs"; import yaml from "yaml"; import { logger } from "../utils/external-logger.js"; +/** + * Clean input schemas for MCP tools whose server-provided JSON Schema declares + * draft 2020-12 ($schema: ".../2020-12/schema"). Mastra's tool-input validator + * can't resolve that meta-schema and rejects every call locally ("no schema with + * key or ref ...2020-12/schema"). Overriding inputSchema with a plain Zod schema + * sidesteps the broken validation — the MCP server still validates server-side. + * Keyed by the registered tool id (serverName_toolName). + */ +const SCHEMA_OVERRIDES: Record = { + firecrawl_firecrawl_scrape: z.object({ + url: z.string().describe("The URL to scrape"), + formats: z.array(z.string()).optional().describe('Output formats, e.g. ["markdown"]'), + onlyMainContent: z.boolean().optional().describe("Strip nav/footer boilerplate (default true)"), + }), + firecrawl_firecrawl_search: z.object({ + query: z.string().describe("The search query"), + limit: z.number().optional().describe("Max results to return"), + }), +}; + let mcpClient: MCPClient | null = null; let mcpTools: Record = {}; @@ -63,7 +84,7 @@ export async function initMCPClient( return [ name, { - url: new URL(cfg.url), + url: new URL(expandEnvVars(cfg.url)), requestInit: headers ? { headers } : undefined, }, ]; @@ -133,6 +154,14 @@ export async function initMCPClient( const MAX_RESULT_CHARS = 30_000; mcpTools = Object.fromEntries( Object.entries(rawTools).map(([name, tool]) => { + // Replace a broken (draft-2020-12) input schema with a clean Zod one. + // Mutate the Tool instance in place: its execute() validates against + // `this.inputSchema` at call time and is lexically bound to this + // instance, so a spread copy would never take effect. + if (SCHEMA_OVERRIDES[name]) { + (tool as { inputSchema?: unknown }).inputSchema = SCHEMA_OVERRIDES[name]; + } + if (!tool.execute) { return [name, tool]; } From 1e9e05e600ba1db85363dd4172b3442e0a5c48f1 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Tue, 23 Jun 2026 00:14:14 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(mcp):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20env-var=20guard,=20override=20no-op=20warning,=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard HTTP MCP urls: throw a clear "empty url; is its env var set?" error instead of an opaque "Invalid URL" when ${FIRECRAWL_MCP_URL} is unset. - Extract the draft-2020-12 schema override into applySchemaOverrides(), which warns when an override's server is loaded but the tool id is missing (e.g. a Firecrawl rename) instead of silently regressing to validation failures. - Add mcp-client.test.ts pinning the load-bearing behaviors: in-place mutation (same reference), the rename warning, no-warn when the server is absent, and that the real SCHEMA_OVERRIDES are valid Zod schemas. Reviewer's secret-in-URL note: confirmed mcp-client logs only server/tool names, never the resolved url. YAML-indent nit: brave and firecrawl blocks already use matching 7-space child indentation; no change. Verified: unit tests pass, lint clean, firecrawl_scrape still scrapes end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent/mcp-client.test.ts | 65 ++++++++++++++++++++++++++++++++++++ src/agent/mcp-client.ts | 53 +++++++++++++++++++++++------ 2 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 src/agent/mcp-client.test.ts diff --git a/src/agent/mcp-client.test.ts b/src/agent/mcp-client.test.ts new file mode 100644 index 0000000..dadfdb1 --- /dev/null +++ b/src/agent/mcp-client.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; + +import { applySchemaOverrides, SCHEMA_OVERRIDES } from "./mcp-client.js"; + +// Regression guard for the draft-2020-12 fix: Firecrawl's MCP tools ship a JSON +// Schema (draft 2020-12) that Mastra's validator can't resolve, so we swap in a +// clean Zod schema by MUTATING the Tool instance in place. These tests pin that +// behavior — if the mutation stops taking effect, or an override silently fails +// to match a renamed tool, the bug returns invisibly in prod. + +const fakeTool = () => ({ inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" } }); + +describe("applySchemaOverrides", () => { + test("mutates the matching tool instance in place (same reference)", () => { + const scrape = fakeTool(); + const tools = { firecrawl_firecrawl_scrape: scrape }; + const override = z.object({ url: z.string() }); + + const applied = applySchemaOverrides(tools, { firecrawl_firecrawl_scrape: override }); + + expect(applied).toEqual(["firecrawl_firecrawl_scrape"]); + // Same object identity — proves we mutated, not replaced (the load-bearing bit). + expect(tools.firecrawl_firecrawl_scrape).toBe(scrape); + expect(scrape.inputSchema).toBe(override); + }); + + test("warns when the server is loaded but the tool id is missing (rename)", () => { + const warnings: string[] = []; + const tools = { firecrawl_some_other_tool: fakeTool() }; + + const applied = applySchemaOverrides( + tools, + { firecrawl_firecrawl_scrape: z.object({ url: z.string() }) }, + (m) => warnings.push(m), + ); + + expect(applied).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("firecrawl_firecrawl_scrape"); + }); + + test("stays silent when the override's server isn't loaded at all", () => { + const warnings: string[] = []; + const tools = { "brave-search_brave_web_search": fakeTool() }; + + applySchemaOverrides( + tools, + { firecrawl_firecrawl_scrape: z.object({ url: z.string() }) }, + (m) => warnings.push(m), + ); + + expect(warnings).toEqual([]); + }); + + test("the real SCHEMA_OVERRIDES are valid Zod schemas keyed by serverName_toolName", () => { + for (const [id, schema] of Object.entries(SCHEMA_OVERRIDES)) { + expect(id).toContain("_"); + expect(typeof (schema as z.ZodTypeAny).parse).toBe("function"); + } + // The two ids the platform actually relies on today. + expect(Object.keys(SCHEMA_OVERRIDES)).toContain("firecrawl_firecrawl_scrape"); + expect(SCHEMA_OVERRIDES.firecrawl_firecrawl_scrape.parse({ url: "https://x.com" })).toEqual({ url: "https://x.com" }); + }); +}); diff --git a/src/agent/mcp-client.ts b/src/agent/mcp-client.ts index f25a491..94c73ff 100644 --- a/src/agent/mcp-client.ts +++ b/src/agent/mcp-client.ts @@ -18,7 +18,7 @@ import { logger } from "../utils/external-logger.js"; * sidesteps the broken validation — the MCP server still validates server-side. * Keyed by the registered tool id (serverName_toolName). */ -const SCHEMA_OVERRIDES: Record = { +export const SCHEMA_OVERRIDES: Record = { firecrawl_firecrawl_scrape: z.object({ url: z.string().describe("The URL to scrape"), formats: z.array(z.string()).optional().describe('Output formats, e.g. ["markdown"]'), @@ -30,6 +30,38 @@ const SCHEMA_OVERRIDES: Record = { }), }; +/** + * Apply {@link SCHEMA_OVERRIDES} to a loaded tool map, mutating each matching + * Tool instance's `inputSchema` in place. In-place is required: `Tool.execute` + * validates against `this.inputSchema` at call time and is an arrow function + * lexically bound to the instance, so a spread copy would never take effect. + * + * If an override's server is loaded but the specific tool id is absent (e.g. the + * server renamed it, or the `serverName_toolName` join changed), the override + * silently no-ops and calls regress to draft-2020-12 validation failures — so we + * warn loudly in that case. Returns the ids that were actually applied. + */ +export function applySchemaOverrides( + tools: Record, + overrides: Record = SCHEMA_OVERRIDES, + warn: (msg: string) => void = (m) => console.warn(m), +): string[] { + const applied: string[] = []; + for (const [id, schema] of Object.entries(overrides)) { + if (id in tools) { + tools[id].inputSchema = schema; + applied.push(id); + continue; + } + const serverName = id.slice(0, id.indexOf("_")); + const serverLoaded = Object.keys(tools).some((t) => t.startsWith(`${serverName}_`)); + if (serverLoaded) { + warn(`[mcp] schema override for "${id}" not applied — no tool with that id loaded (renamed?). Calls may fail draft-2020-12 validation.`); + } + } + return applied; +} + let mcpClient: MCPClient | null = null; let mcpTools: Record = {}; @@ -76,6 +108,12 @@ export async function initMCPClient( Object.entries(servers).map(([name, cfg]) => { // HTTP server (url-based) if (cfg.url) { + const resolvedUrl = expandEnvVars(cfg.url); + if (!resolvedUrl) { + // expandEnvVars yields "" for an unset var, which would throw an + // opaque "Invalid URL" — point at the missing var instead. + throw new Error(`[mcp] server "${name}" has an empty url; is its env var set? (template: ${cfg.url})`); + } const headers = cfg.headers ? Object.fromEntries( Object.entries(cfg.headers).map(([k, v]) => [k, expandEnvVars(v)]) @@ -84,7 +122,7 @@ export async function initMCPClient( return [ name, { - url: new URL(expandEnvVars(cfg.url)), + url: new URL(resolvedUrl), requestInit: headers ? { headers } : undefined, }, ]; @@ -150,18 +188,13 @@ export async function initMCPClient( console.log(`[mcp] filtered ${Object.keys(allTools).length} → ${Object.keys(rawTools).length} tool(s) via whitelist`); } + // Replace broken (draft-2020-12) input schemas with clean Zod ones. + applySchemaOverrides(rawTools as Record); + // Wrap MCP tools to cap result size (prevent context overflow) const MAX_RESULT_CHARS = 30_000; mcpTools = Object.fromEntries( Object.entries(rawTools).map(([name, tool]) => { - // Replace a broken (draft-2020-12) input schema with a clean Zod one. - // Mutate the Tool instance in place: its execute() validates against - // `this.inputSchema` at call time and is lexically bound to this - // instance, so a spread copy would never take effect. - if (SCHEMA_OVERRIDES[name]) { - (tool as { inputSchema?: unknown }).inputSchema = SCHEMA_OVERRIDES[name]; - } - if (!tool.execute) { return [name, tool]; } From 8aadfaf40611842b15e89ba57e1b90744118d2a9 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Tue, 23 Jun 2026 00:16:14 +0300 Subject: [PATCH 3/3] fix(test): type fakeTool inputSchema as unknown to satisfy tsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI typecheck (tsc --noEmit, not run by the lint-only pre-commit hook) flagged toBe(zodSchema) against an inferred { $schema: string }. Type the fake tool's inputSchema as unknown — mirroring the real Tool, whose schema is replaced from a JSON-schema object to a Zod schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent/mcp-client.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/agent/mcp-client.test.ts b/src/agent/mcp-client.test.ts index dadfdb1..acb39f0 100644 --- a/src/agent/mcp-client.test.ts +++ b/src/agent/mcp-client.test.ts @@ -9,7 +9,11 @@ import { applySchemaOverrides, SCHEMA_OVERRIDES } from "./mcp-client.js"; // behavior — if the mutation stops taking effect, or an override silently fails // to match a renamed tool, the bug returns invisibly in prod. -const fakeTool = () => ({ inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" } }); +// inputSchema typed as `unknown` to mirror the real Tool (the override replaces +// a JSON-schema object with a Zod schema, so the field must accept both). +const fakeTool = (): { inputSchema: unknown } => ({ + inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" }, +}); describe("applySchemaOverrides", () => { test("mutates the matching tool instance in place (same reference)", () => {