fix(mcp): isolate schema validators per client

This commit is contained in:
Aiden Cline 2026-07-28 11:12:43 -05:00
commit b087a788b4
3 changed files with 50 additions and 31 deletions

View file

@ -5,7 +5,7 @@ import { Cause } from "effect"
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
import { CLIENT_OPTIONS, MCP } from "../../mcp" import { clientOptions, MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth" import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider" import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config" import { Config } from "@/config/config"
@ -751,7 +751,7 @@ export const McpDebugCommand = effectCmd({
authProvider, authProvider,
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, 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 { try {
await client.connect(transport) await client.connect(transport)

View file

@ -38,33 +38,36 @@ import { McpBrowser } from "./browser"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
const DEFAULT_TIMEOUT = 30_000 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 = { export function clientOptions(): ClientOptions {
capabilities: { const draft7Validator = lazy(() => {
// https://github.com/anomalyco/opencode/issues/11948 const ajv = new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true })
// sampling: {}, addFormats(ajv)
// https://github.com/anomalyco/opencode/issues/23066 return new AjvJsonSchemaValidator(ajv)
// elicitation: {}, })
// https://github.com/anomalyco/opencode/issues/2308 const defaultValidator = new AjvJsonSchemaValidator()
roots: {},
// https://github.com/anomalyco/opencode/issues/28567 return {
// tasks: {}, capabilities: {
}, // https://github.com/anomalyco/opencode/issues/11948
versionNegotiation: { mode: "auto" }, // sampling: {},
listMaxPages: 1_000, // https://github.com/anomalyco/opencode/issues/23066
jsonSchemaValidator: { // elicitation: {},
getValidator: <T>(schema: { $schema?: string }) => { // https://github.com/anomalyco/opencode/issues/2308
if (!schema.$schema?.toLowerCase().includes("draft-07")) return defaultValidator.getValidator<T>(schema) roots: {},
return draft7Validator().getValidator<T>(schema) // https://github.com/anomalyco/opencode/issues/28567
// tasks: {},
}, },
}, versionNegotiation: { mode: "auto" },
} satisfies ClientOptions listMaxPages: 1_000,
jsonSchemaValidator: {
getValidator: <T>(schema: { $schema?: string }) => {
if (!schema.$schema?.toLowerCase().includes("draft-07")) return defaultValidator.getValidator<T>(schema)
return draft7Validator().getValidator<T>(schema)
},
},
}
}
export const Resource = Schema.Struct({ export const Resource = Schema.Struct({
name: Schema.String, name: Schema.String,
@ -93,7 +96,7 @@ function createClient(directory: string) {
const client: MCPClient = new Client( const client: MCPClient = new Client(
{ name: "opencode", version: InstallationVersion }, { name: "opencode", version: InstallationVersion },
{ {
...CLIENT_OPTIONS, ...clientOptions(),
listChanged: { listChanged: {
tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) },
}, },

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Client, InMemoryTransport } from "@modelcontextprotocol/client" import { Client, InMemoryTransport } from "@modelcontextprotocol/client"
import { Server } from "@modelcontextprotocol/server" import { Server } from "@modelcontextprotocol/server"
import { McpCatalog } from "@/mcp/catalog" import { McpCatalog } from "@/mcp/catalog"
import { CLIENT_OPTIONS } from "@/mcp" import { clientOptions } from "@/mcp"
import { Effect } from "effect" import { Effect } from "effect"
const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any 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 } }) 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() const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) 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()]) 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)
})