diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e7d18f4b9..b5c525c707f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai - Sessions: honor configured `session.maintenance` settings during load-time maintenance instead of falling back to default entry caps. Fixes #71356. Thanks @comolago. - Browser/sandbox: pass the resolved `browser.ssrfPolicy` into sandbox browser bridges and refresh cached bridges when the effective policy changes, so sandboxed browser navigation honors private-network opt-ins. Fixes #45153 and #57055. Thanks @jzakirov, @zuoanCo, and @kybrcore. - Browser/proxy: keep Gateway/provider proxy environment variables from proxying the OpenClaw-managed browser, so `HTTP_PROXY` and `HTTPS_PROXY` no longer block ordinary browser navigation. Fixes #71358. Thanks @Sanjays2402. +- Agents/MCP: validate draft-2020-12 MCP tool output schemas with a draft-aware bundle-MCP client validator, so external MCP servers no longer fail catalog/tool execution with missing schema refs. Fixes #68772 and #70196. Thanks @mwiesen. - Dashboard/Windows: open Control UI and OAuth URLs through the system URL handler without `cmd.exe` parsing or PATH-based `rundll32` lookup, and reject non-HTTP browser-open inputs. Fixes #71098. Thanks @Sanjays2402. - Config/doctor: reject legacy `secretref-env:` marker strings on SecretRef credential paths and migrate valid markers to structured env SecretRefs with `openclaw doctor --fix`. Fixes #51794. Thanks @halointellicore. - Providers/OpenAI: separate API-key and Codex sign-in onboarding groups, and avoid replaying stale OpenAI Responses reasoning blocks after a model route switch. diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 456236ac78d..2f2eac1a6bb 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createBundleMcpJsonSchemaValidator } from "./pi-bundle-mcp-runtime.js"; import { cleanupBundleMcpHarness } from "./pi-bundle-mcp-test-harness.js"; import { __testing, @@ -69,6 +70,25 @@ afterEach(async () => { }); describe("session MCP runtime", () => { + it("accepts draft-2020-12 tool output schemas from external MCP catalogs", () => { + const validator = createBundleMcpJsonSchemaValidator().getValidator<{ url: string }>({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + additionalProperties: false, + }); + + expect(validator({ url: "https://example.com" })).toEqual({ + valid: true, + data: { url: "https://example.com" }, + errorMessage: undefined, + }); + expect(validator({ url: 42 }).valid).toBe(false); + }); + it("keeps colliding sanitized tool definitions stable across catalog order changes", async () => { const catalogA = [ { toolName: "alpha?", description: "question" }, diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/pi-bundle-mcp-runtime.ts index f525ff4da6d..801a4039185 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/pi-bundle-mcp-runtime.ts @@ -1,8 +1,16 @@ import crypto from "node:crypto"; +import { createRequire } from "node:module"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js"; +import type { + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, +} from "@modelcontextprotocol/sdk/validation/types.js"; +import type { ErrorObject, ValidateFunction } from "ajv"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; @@ -34,7 +42,53 @@ type CreateSessionMcpRuntime = ( params: Parameters[0] & { configFingerprint?: string }, ) => SessionMcpRuntime; +const require = createRequire(import.meta.url); const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager"); +const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema"; + +type Ajv2020Like = { + compile: (schema: JsonSchemaType) => ValidateFunction; + errorsText: (errors?: ErrorObject[] | null) => string; +}; + +function isDraft202012Schema(schema: JsonSchemaType): boolean { + return (schema as { $schema?: unknown }).$schema === DRAFT_2020_12_SCHEMA; +} + +export function createBundleMcpJsonSchemaValidator(): jsonSchemaValidator { + const defaultValidator = new AjvJsonSchemaValidator(); + const Ajv2020Ctor = require("ajv/dist/2020") as new (opts?: object) => Ajv2020Like; + const ajv2020 = new Ajv2020Ctor({ + strict: false, + validateFormats: false, + validateSchema: false, + allErrors: true, + }); + + return { + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + if (!isDraft202012Schema(schema)) { + return defaultValidator.getValidator(schema); + } + const ajvValidator = ajv2020.compile(schema); + return (input: unknown) => { + const valid = ajvValidator(input); + if (valid) { + return { + valid: true, + data: input as T, + errorMessage: undefined, + }; + } + return { + valid: false, + data: undefined, + errorMessage: ajv2020.errorsText(ajvValidator.errors), + }; + }; + }, + }; +} function connectWithTimeout( client: Client, @@ -178,7 +232,9 @@ export function createSessionMcpRuntime(params: { name: "openclaw-bundle-mcp", version: "0.0.0", }, - {}, + { + jsonSchemaValidator: createBundleMcpJsonSchemaValidator(), + }, ); const session: BundleMcpSession = { serverName,