Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline b087a788b4 fix(mcp): isolate schema validators per client 2026-07-28 11:12:43 -05:00
Aiden Cline 87c86b33b4 fix(mcp): support draft-07 output schemas 2026-07-28 11:08:36 -05:00
3 changed files with 93 additions and 17 deletions
+2 -2
View File
@@ -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)
+33 -15
View File
@@ -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: <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({
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) },
},
@@ -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)
})