diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 939c442047..b6cb74e4fa 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -15,6 +15,7 @@ import { type Tool as MCPToolDef, } from "@modelcontextprotocol/client" import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" +import { Ajv, AjvJsonSchemaValidator, addFormats } from "@modelcontextprotocol/client/validators/ajv" import { Config } from "@/config/config" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { NamedError } from "@opencode-ai/core/util/error" @@ -34,8 +35,16 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" +import { lazy } from "@/util/lazy" const DEFAULT_TIMEOUT = 30_000 +const draft7Validator = lazy(() => { + const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }) + addFormats(ajv) + return new AjvJsonSchemaValidator(ajv) +}) +const defaultValidator = new AjvJsonSchemaValidator() + export const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 @@ -49,6 +58,12 @@ export const CLIENT_OPTIONS = { }, versionNegotiation: { mode: "auto" }, listMaxPages: 1_000, + jsonSchemaValidator: { + getValidator: (schema: { $schema?: string }) => { + if (!schema.$schema?.toLowerCase().includes("draft-07")) return defaultValidator.getValidator(schema) + return draft7Validator().getValidator(schema) + }, + }, } satisfies ClientOptions export const Resource = Schema.Struct({ diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 3d77cb22b7..6b832cb98c 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Client, InMemoryTransport } from "@modelcontextprotocol/client" import { Server } from "@modelcontextprotocol/server" import { McpCatalog } from "@/mcp/catalog" +import { CLIENT_OPTIONS } from "@/mcp" import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -141,3 +142,44 @@ test("preserves output schema validation across paginated tool discovery", async await Promise.all([client.close(), server.close()]) } }) + +test("accepts and validates draft-07 tool output schemas", async () => { + const server = new Server({ name: "draft-07", version: "1.0.0" }, { capabilities: { tools: {} } }) + let calls = 0 + server.setRequestHandler("tools/list", () => + Promise.resolve({ + tools: [ + { + name: "draft-07-tool", + inputSchema: { type: "object" as const }, + outputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object" as const, + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + }), + ) + server.setRequestHandler("tools/call", () => { + calls++ + return Promise.resolve({ content: [], structuredContent: { value: calls === 1 ? "valid" : 42 } }) + }) + + const client = new Client({ name: "draft-07-test", version: "1.0.0" }, CLIENT_OPTIONS) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["draft-07-tool"]) + await expect(client.callTool({ name: "draft-07-tool", arguments: {} })).resolves.toMatchObject({ + structuredContent: { value: "valid" }, + }) + await expect(client.callTool({ name: "draft-07-tool", arguments: {} })).rejects.toThrow(/output schema/i) + expect(calls).toBe(2) + } finally { + await Promise.all([client.close(), server.close()]) + } +})