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 939c442047..ec7be68b40 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,22 +35,39 @@ 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 -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, -} satisfies ClientOptions + +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: {}, + }, + 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, @@ -78,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 3d77cb22b7..aa1543cec9 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 { clientOptions } from "@/mcp" import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -141,3 +142,60 @@ 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" }, clientOptions()) + 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()]) + } +}) + +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) +})