From 463af5e07933828cdfda0f766082baa18c415803 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 16:39:56 -0400 Subject: [PATCH 1/2] fix(session): reject tool schemas with zod internals --- packages/opencode/src/session/tools.ts | 30 +++ .../test/session/tools-schema.test.ts | 201 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 packages/opencode/test/session/tools-schema.test.ts diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f45df9d0fa..40a469f40a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -20,6 +20,7 @@ import * as Log from "@opencode-ai/core/util/log" import { EffectBridge } from "@/effect/bridge" const log = Log.create({ service: "session.tools" }) +const schemaInternalKeys = new Set(["_def", "def", "_zod", "~standard", "_cached", "typeName"]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -78,6 +79,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: input.agent, })) { const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) + assertNoSchemaInternals(item.id, schema) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), @@ -121,6 +123,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) const transformed = ProviderTransform.schema(input.model, schema) + assertNoSchemaInternals(key, transformed) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => run.promise( @@ -205,4 +208,31 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { return tools }) +function assertNoSchemaInternals(toolID: string, schema: unknown) { + const path = schemaInternalPath(schema) + if (!path) return + throw new Error(`Tool ${toolID} input schema contains non-JSON-Schema Zod internals at ${path}`) +} + +function schemaInternalPath(value: unknown, path = "$", skipKeys = false): string | undefined { + if (Array.isArray(value)) { + return value + .map((item, index) => schemaInternalPath(item, `${path}[${index}]`)) + .find((item): item is string => item !== undefined) + } + if (typeof value !== "object" || value === null) return undefined + + for (const [key, item] of Object.entries(value)) { + const nextPath = /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]` + if (!skipKeys && schemaInternalKeys.has(key)) return nextPath + const found = schemaInternalPath( + item, + nextPath, + key === "properties" || key === "$defs" || key === "definitions" || key === "patternProperties", + ) + if (found) return found + } + return undefined +} + export * as SessionTools from "./tools" diff --git a/packages/opencode/test/session/tools-schema.test.ts b/packages/opencode/test/session/tools-schema.test.ts new file mode 100644 index 0000000000..413566b1ef --- /dev/null +++ b/packages/opencode/test/session/tools-schema.test.ts @@ -0,0 +1,201 @@ +import { describe, expect } from "bun:test" +import { jsonSchema } from "ai" +import { Effect, Exit, Layer } from "effect" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { ProjectID } from "@/project/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import type { Provider } from "@/provider/provider" +import { SessionTools } from "@/session/tools" +import { MessageV2 } from "@/session/message-v2" +import { MessageID, SessionID } from "@/session/schema" +import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool/truncate" +import { Plugin } from "@/plugin" +import { testEffect } from "../lib/effect" + +const it = testEffect( + Layer.mergeAll( + Layer.succeed( + ToolRegistry.Service, + ToolRegistry.Service.of({ + ids: () => Effect.succeed([]), + all: () => Effect.succeed([]), + named: () => Effect.die("unexpected named tool lookup"), + tools: () => Effect.succeed([]), + }), + ), + Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => + Effect.succeed({ + ctx_batch_execute: { + description: "context tool", + inputSchema: jsonSchema({ + type: "object", + properties: { + batch: { + type: "array", + items: schemaWithZodInternals(), + }, + }, + }), + execute: () => Promise.resolve({ content: [{ type: "text" as const, text: "ok" }] }), + }, + }), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), + ), + Layer.succeed( + Plugin.Service, + Plugin.Service.of({ + trigger: (_name, _input, output) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + }), + ), + Layer.succeed( + Permission.Service, + Permission.Service.of({ + ask: () => Effect.void, + reply: () => Effect.void, + list: () => Effect.succeed([]), + }), + ), + Layer.succeed( + Truncate.Service, + Truncate.Service.of({ + cleanup: () => Effect.void, + write: () => Effect.succeed("/tmp/tool-output"), + output: (text) => Effect.succeed({ content: text, truncated: false as const }), + limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }), + }), + ), + ), +) + +describe("SessionTools.resolve", () => { + it.effect("fails locally when MCP schemas contain Zod internals", () => + Effect.gen(function* () { + const exit = yield* SessionTools.resolve({ + agent: agentInfo(), + model: kimiModel(), + session: sessionInfo(), + processor: processor(), + bypassAgentCheck: false, + messages: [], + promptOps: promptOps(), + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + + expect(String(exit.cause)).toContain("ctx_batch_execute") + expect(String(exit.cause)).toContain("non-JSON-Schema Zod internals") + expect(String(exit.cause)).toContain("$.properties.batch.items._zod") + }), + ) +}) + +function agentInfo(): Agent.Info { + return { + name: "build", + mode: "primary", + permission: [], + options: {}, + } +} + +function schemaWithZodInternals() { + return JSON.parse( + JSON.stringify({ + _zod: { def: { type: "object" } }, + def: { type: "object" }, + typeName: "ZodObject", + "~standard": { vendor: "zod" }, + }), + ) +} + +function kimiModel(): Provider.Model { + return { + id: ModelID.make("kimi-k2.6"), + providerID: ProviderID.make("moonshotai"), + name: "Kimi K2.6", + limit: { context: 128_000, output: 32_000 }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + interleaved: false, + }, + api: { id: "kimi-k2.6", url: "https://api.moonshot.example/v1", npm: "@ai-sdk/openai-compatible" }, + options: {}, + headers: {}, + release_date: "2026-01-01", + status: "active", + } +} + +function sessionInfo() { + return { + id: SessionID.descending(), + slug: "test", + projectID: ProjectID.global, + directory: "/tmp/test", + title: "test", + version: "test", + time: { created: Date.now(), updated: Date.now() }, + } +} + +function processor() { + return { + message: { + id: MessageID.ascending(), + sessionID: SessionID.descending(), + role: "assistant", + parentID: MessageID.ascending(), + modelID: ModelID.make("kimi-k2.6"), + providerID: ProviderID.make("moonshotai"), + mode: "build", + agent: "build", + path: { cwd: "/tmp/test", root: "/tmp/test" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + } satisfies MessageV2.Assistant, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + } +} + +function promptOps() { + return { + cancel: () => Effect.void, + resolvePromptParts: (template: string) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: () => Effect.die("unexpected prompt call"), + loop: () => Effect.die("unexpected loop call"), + } +} From 7fa8ad4ec22b55386fe956f237d7b09c218018ad Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 17:26:53 -0400 Subject: [PATCH 2/2] fix(tool): support zod 3 plugin schemas --- packages/opencode/src/tool/registry.ts | 29 +++++-- packages/opencode/test/tool/registry.test.ts | 86 +++++++++++++++++++- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ef6d39a65..67df37c34a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -55,6 +55,13 @@ import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { SessionStatus } from "@/session/status" import { RuntimeFlags } from "@/effect/runtime-flags" +import { + objectFromShape, + safeParse, + type AnyObjectSchema, + type AnySchema, +} from "@modelcontextprotocol/sdk/server/zod-compat.js" +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js" const log = Log.create({ service: "tool.registry" }) @@ -150,10 +157,10 @@ export const layer: Layer.Layer< const args = def.args ?? {} const entries = Object.entries(args) const allZod = entries.every((entry) => isZodType(entry[1])) - const zodParams = allZod ? z.object(args) : undefined + const zodParams = allZod ? objectFromShape(args as Record) : undefined const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) const parameters = zodParams - ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) + ? Schema.declare((u): u is unknown => safeParse(zodParams, u).success) : Schema.Unknown return { id, @@ -402,7 +409,11 @@ export const defaultLayer = Layer.suspend(() => .pipe(Layer.provide(RuntimeFlags.defaultLayer)), ) -function isZodType(value: unknown): value is z.ZodType { +function isZodType(value: unknown): value is AnySchema { + return typeof value === "object" && value !== null && ("_zod" in value || "_def" in value) +} + +function isZod4Type(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } @@ -425,8 +436,12 @@ function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { } } -function zodJsonSchema(schema: z.ZodType): JSONSchema7 { - const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) })) +function zodJsonSchema(schema: AnyObjectSchema): JSONSchema7 { + const result = normalizeZodJsonSchema( + isZod4Type(schema) + ? z.toJSONSchema(schema, { io: "input", metadata: zodMetadataRegistry(schema) }) + : toJsonSchemaCompat(schema, { pipeStrategy: "input" }), + ) if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") const { $defs, ...rest } = result return ( @@ -434,7 +449,7 @@ function zodJsonSchema(schema: z.ZodType): JSONSchema7 { ) as JSONSchema7 } -function zodMetadataRegistry(schema: z.ZodType) { +function zodMetadataRegistry(schema: AnyObjectSchema) { const registry = z.registry>() const seen = new WeakSet() const collect = (value: unknown) => { @@ -442,7 +457,7 @@ function zodMetadataRegistry(schema: z.ZodType) { if (seen.has(value)) return seen.add(value) - if (isZodType(value)) { + if (isZod4Type(value)) { const metadata = typeof value.meta === "function" ? value.meta() : undefined const description = typeof value.description === "string" ? value.description : undefined const merged = { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d3549e66f3..aef5d2c49a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -34,6 +34,7 @@ import { ProviderID, ModelID } from "@/provider/schema" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import z3 from "zod/v3" const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -80,7 +81,7 @@ const brokenPluginLayer = Layer.succeed( init: () => Effect.void, trigger: ((_name: unknown, _input: unknown, output: unknown) => Effect.succeed(output)) as Plugin.Interface["trigger"], - list: () => + list: (() => Effect.succeed([ { tool: { @@ -91,7 +92,41 @@ const brokenPluginLayer = Layer.succeed( }, }, }, - ]), + ])) as unknown as Plugin.Interface["list"], + }), +) + +const zod3PluginLayer = Layer.succeed( + Plugin.Service, + Plugin.Service.of({ + init: () => Effect.void, + trigger: ((_name: unknown, _input: unknown, output: unknown) => + Effect.succeed(output)) as Plugin.Interface["trigger"], + list: (() => + Effect.succeed([ + { + tool: { + ctx_batch_execute: { + description: "context-mode batch executor", + args: { + batch: z3 + .preprocess( + (value) => (typeof value === "string" ? JSON.parse(value) : value), + z3 + .array( + z3.object({ + command: z3.string().describe("Command to execute"), + }), + ) + .min(1), + ) + .describe("Commands to execute as a batch"), + }, + execute: async () => "ok", + }, + }, + }, + ])) as unknown as Plugin.Interface["list"], }), ) @@ -105,6 +140,7 @@ const background = testEffect( const withBrokenPlugin = testEffect( Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), ) +const withZod3Plugin = testEffect(Layer.mergeAll(registryLayer({ plugin: zod3PluginLayer }), node, Agent.defaultLayer)) afterEach(async () => { await disposeAllInstances() @@ -349,6 +385,52 @@ describe("tool.registry", () => { }), ) + withZod3Plugin.instance("loads plugin tools with Zod 3 args as JSON Schema", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute") + if (!loaded) throw new Error("ctx_batch_execute tool was not loaded") + + expect(loaded.jsonSchema).toMatchObject({ + type: "object", + properties: { + batch: { + type: "array", + description: "Commands to execute as a batch", + items: { + type: "object", + properties: { + command: { type: "string", description: "Command to execute" }, + }, + }, + minItems: 1, + }, + }, + required: ["batch"], + }) + expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_def") + expect(JSON.stringify(loaded.jsonSchema)).not.toContain("_zod") + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ batch: [{ command: "pwd" }] }))).toBe( + true, + ) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) + }), + ) + + withZod3Plugin.instance("validates plugin tools with Zod 3 preprocessors", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "ctx_batch_execute") + if (!loaded) throw new Error("ctx_batch_execute tool was not loaded") + + expect( + Result.isSuccess( + Schema.decodeUnknownResult(loaded.parameters)({ batch: JSON.stringify([{ command: "pwd" }]) }), + ), + ).toBe(true) + }), + ) + it.instance( "preserves Zod arg descriptions from older config-scoped plugin packages", () =>