From b087a788b494ec23bdefa2d4f8e864a31b1683be Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 28 Jul 2026 11:12:43 -0500 Subject: [PATCH] fix(mcp): isolate schema validators per client --- packages/opencode/src/cli/cmd/mcp.ts | 4 +- packages/opencode/src/mcp/index.ts | 55 ++++++++++++---------- packages/opencode/test/mcp/catalog.test.ts | 20 +++++++- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c9f74e2bc0..ba51259e26 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -5,7 +5,7 @@ import { Cause } from "effect" import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { CLIENT_OPTIONS, MCP } from "../../mcp" +import { clientOptions, MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -751,7 +751,7 @@ export const McpDebugCommand = effectCmd({ authProvider, requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, }) - const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS) + const client = new Client({ name: "opencode-debug", version: InstallationVersion }, clientOptions()) try { await client.connect(transport) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index b6cb74e4fa..ec7be68b40 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -38,33 +38,36 @@ 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 - // sampling: {}, - // https://github.com/anomalyco/opencode/issues/23066 - // elicitation: {}, - // https://github.com/anomalyco/opencode/issues/2308 - roots: {}, - // https://github.com/anomalyco/opencode/issues/28567 - // tasks: {}, - }, - 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) +export function clientOptions(): ClientOptions { + 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() + + return { + capabilities: { + // https://github.com/anomalyco/opencode/issues/11948 + // sampling: {}, + // https://github.com/anomalyco/opencode/issues/23066 + // elicitation: {}, + // https://github.com/anomalyco/opencode/issues/2308 + roots: {}, + // https://github.com/anomalyco/opencode/issues/28567 + // tasks: {}, }, - }, -} satisfies ClientOptions + 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) + }, + }, + } +} export const Resource = Schema.Struct({ name: Schema.String, @@ -93,7 +96,7 @@ function createClient(directory: string) { const client: MCPClient = new Client( { name: "opencode", version: InstallationVersion }, { - ...CLIENT_OPTIONS, + ...clientOptions(), listChanged: { tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, }, diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 6b832cb98c..aa1543cec9 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -2,7 +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 { clientOptions } from "@/mcp" import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -167,7 +167,7 @@ test("accepts and validates draft-07 tool output schemas", async () => { 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 client = new Client({ name: "draft-07-test", version: "1.0.0" }, clientOptions()) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) @@ -183,3 +183,19 @@ test("accepts and validates draft-07 tool output schemas", async () => { await Promise.all([client.close(), server.close()]) } }) + +test("isolates output schema caches between MCP clients", () => { + const stringSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://example.com/shared-schema", + type: "string", + } + const numberSchema = { ...stringSchema, type: "number" } + const stringValidator = clientOptions().jsonSchemaValidator!.getValidator(stringSchema) + const numberValidator = clientOptions().jsonSchemaValidator!.getValidator(numberSchema) + + expect(stringValidator("value").valid).toBe(true) + expect(stringValidator(42).valid).toBe(false) + expect(numberValidator(42).valid).toBe(true) + expect(numberValidator("value").valid).toBe(false) +})