Преглед изворни кода

feat: add dynamic tool registration for plugins and external services (#2420)

Zack Jackson пре 5 месеци
родитељ
комит
ab3c22b77a

+ 8 - 0
packages/opencode/src/plugin/index.ts

@@ -7,6 +7,7 @@ import { Server } from "../server/server"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
 import { Flag } from "../flag/flag"
 import { Flag } from "../flag/flag"
+import { ToolRegistry } from "../tool/registry"
 
 
 export namespace Plugin {
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
   const log = Log.create({ service: "plugin" })
@@ -24,6 +25,8 @@ export namespace Plugin {
       worktree: Instance.worktree,
       worktree: Instance.worktree,
       directory: Instance.directory,
       directory: Instance.directory,
       $: Bun.$,
       $: Bun.$,
+      Tool: await import("../tool/tool").then(m => m.Tool),
+      z: await import("zod").then(m => m.z),
     }
     }
     const plugins = [...(config.plugin ?? [])]
     const plugins = [...(config.plugin ?? [])]
     if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
     if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
@@ -75,6 +78,11 @@ export namespace Plugin {
     const config = await Config.get()
     const config = await Config.get()
     for (const hook of hooks) {
     for (const hook of hooks) {
       await hook.config?.(config)
       await hook.config?.(config)
+      // Let plugins register tools at startup
+      await hook["tool.register"]?.({}, { 
+        registerHTTP: ToolRegistry.registerHTTP,
+        register: ToolRegistry.register 
+      })
     }
     }
     Bus.subscribeAll(async (input) => {
     Bus.subscribeAll(async (input) => {
       const hooks = await state().then((x) => x.hooks)
       const hooks = await state().then((x) => x.hooks)

+ 118 - 0
packages/opencode/src/server/server.ts

@@ -23,6 +23,8 @@ import { Auth } from "../auth"
 import { Command } from "../command"
 import { Command } from "../command"
 import { Global } from "../global"
 import { Global } from "../global"
 import { ProjectRoute } from "./project"
 import { ProjectRoute } from "./project"
+import { ToolRegistry } from "../tool/registry"
+import { zodToJsonSchema } from "zod-to-json-schema"
 
 
 const ERRORS = {
 const ERRORS = {
   400: {
   400: {
@@ -46,6 +48,29 @@ const ERRORS = {
 export namespace Server {
 export namespace Server {
   const log = Log.create({ service: "server" })
   const log = Log.create({ service: "server" })
 
 
+  // Schemas for HTTP tool registration
+  const HttpParamSpec = z
+    .object({
+      type: z.enum(["string", "number", "boolean", "array"]),
+      description: z.string().optional(),
+      optional: z.boolean().optional(),
+      items: z.enum(["string", "number", "boolean"]).optional(),
+    })
+    .openapi({ ref: "HttpParamSpec" })
+
+  const HttpToolRegistration = z
+    .object({
+      id: z.string(),
+      description: z.string(),
+      parameters: z.object({
+        type: z.literal("object"),
+        properties: z.record(HttpParamSpec),
+      }),
+      callbackUrl: z.string(),
+      headers: z.record(z.string(), z.string()).optional(),
+    })
+    .openapi({ ref: "HttpToolRegistration" })
+
   export const Event = {
   export const Event = {
     Connected: Bus.event("server.connected", z.object({})),
     Connected: Bus.event("server.connected", z.object({})),
   }
   }
@@ -166,6 +191,99 @@ export namespace Server {
         return c.json(await Config.get())
         return c.json(await Config.get())
       },
       },
     )
     )
+    .post(
+      "/experimental/tool/register",
+      describeRoute({
+        description: "Register a new HTTP callback tool",
+        operationId: "tool.register",
+        responses: {
+          200: {
+            description: "Tool registered successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...ERRORS,
+        },
+      }),
+      zValidator("json", HttpToolRegistration),
+      async (c) => {
+        ToolRegistry.registerHTTP(c.req.valid("json"))
+        return c.json(true)
+      },
+    )
+    .get(
+      "/experimental/tool/ids",
+      describeRoute({
+        description: "List all tool IDs (including built-in and dynamically registered)",
+        operationId: "tool.ids",
+        responses: {
+          200: {
+            description: "Tool IDs",
+            content: {
+              "application/json": {
+                schema: resolver(z.array(z.string()).openapi({ ref: "ToolIDs" })),
+              },
+            },
+          },
+          ...ERRORS,
+        },
+      }),
+      async (c) => {
+        return c.json(ToolRegistry.ids())
+      },
+    )
+    .get(
+      "/experimental/tool",
+      describeRoute({
+        description: "List tools with JSON schema parameters for a provider/model",
+        operationId: "tool.list",
+        responses: {
+          200: {
+            description: "Tools",
+            content: {
+              "application/json": {
+                schema: resolver(
+                  z
+                    .array(
+                      z
+                        .object({
+                          id: z.string(),
+                          description: z.string(),
+                          parameters: z.any(),
+                        })
+                        .openapi({ ref: "ToolListItem" }),
+                    )
+                    .openapi({ ref: "ToolList" }),
+                ),
+              },
+            },
+          },
+          ...ERRORS,
+        },
+      }),
+      zValidator(
+        "query",
+        z.object({
+          provider: z.string(),
+          model: z.string(),
+        }),
+      ),
+      async (c) => {
+        const { provider, model } = c.req.valid("query")
+        const tools = await ToolRegistry.tools(provider, model)
+        return c.json(
+          tools.map((t) => ({
+            id: t.id,
+            description: t.description,
+            // Handle both Zod schemas and plain JSON schemas
+            parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+          })),
+        )
+      },
+    )
     .get(
     .get(
       "/path",
       "/path",
       describeRoute({
       describeRoute({

+ 98 - 6
packages/opencode/src/tool/registry.ts

@@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch"
 import { WriteTool } from "./write"
 import { WriteTool } from "./write"
 import { InvalidTool } from "./invalid"
 import { InvalidTool } from "./invalid"
 import type { Agent } from "../agent/agent"
 import type { Agent } from "../agent/agent"
+import { Tool } from "./tool"
 
 
 export namespace ToolRegistry {
 export namespace ToolRegistry {
-  const ALL = [
+  // Built-in tools that ship with opencode
+  const BUILTIN = [
     InvalidTool,
     InvalidTool,
     BashTool,
     BashTool,
     EditTool,
     EditTool,
@@ -30,13 +32,103 @@ export namespace ToolRegistry {
     TaskTool,
     TaskTool,
   ]
   ]
 
 
+  // Extra tools registered at runtime (via plugins)
+  const EXTRA: Tool.Info[] = []
+
+  // Tools registered via HTTP callback (via SDK/API)
+  const HTTP: Tool.Info[] = []
+
+  export type HttpParamSpec = {
+    type: "string" | "number" | "boolean" | "array"
+    description?: string
+    optional?: boolean
+    items?: "string" | "number" | "boolean"
+  }
+  export type HttpToolRegistration = {
+    id: string
+    description: string
+    parameters: {
+      type: "object"
+      properties: Record<string, HttpParamSpec>
+    }
+    callbackUrl: string
+    headers?: Record<string, string>
+  }
+
+  function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) {
+    const shape: Record<string, z.ZodTypeAny> = {}
+    for (const [key, val] of Object.entries(spec.properties)) {
+      let base: z.ZodTypeAny
+      switch (val.type) {
+        case "string":
+          base = z.string()
+          break
+        case "number":
+          base = z.number()
+          break
+        case "boolean":
+          base = z.boolean()
+          break
+        case "array":
+          if (!val.items) throw new Error(`array spec for ${key} requires 'items'`)
+          base = z.array(
+            val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean(),
+          )
+          break
+        default:
+          base = z.any()
+      }
+      if (val.description) base = base.describe(val.description)
+      shape[key] = val.optional ? base.optional() : base
+    }
+    return z.object(shape)
+  }
+
+  export function register(tool: Tool.Info) {
+    // Prevent duplicates by id (replace existing)
+    const idx = EXTRA.findIndex((t) => t.id === tool.id)
+    if (idx >= 0) EXTRA.splice(idx, 1, tool)
+    else EXTRA.push(tool)
+  }
+
+  export function registerHTTP(input: HttpToolRegistration) {
+    const parameters = buildZodFromHttpSpec(input.parameters)
+    const info = Tool.define(input.id, {
+      description: input.description,
+      parameters,
+      async execute(args) {
+        const res = await fetch(input.callbackUrl, {
+          method: "POST",
+          headers: { "content-type": "application/json", ...(input.headers ?? {}) },
+          body: JSON.stringify({ args }),
+        })
+        if (!res.ok) {
+          throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`)
+        }
+        const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> }
+        return {
+          title: json.title ?? input.id,
+          output: json.output ?? "",
+          metadata: (json.metadata ?? {}) as any,
+        }
+      },
+    })
+    const idx = HTTP.findIndex((t) => t.id === info.id)
+    if (idx >= 0) HTTP.splice(idx, 1, info)
+    else HTTP.push(info)
+  }
+
+  function allTools(): Tool.Info[] {
+    return [...BUILTIN, ...EXTRA, ...HTTP]
+  }
+
   export function ids() {
   export function ids() {
-    return ALL.map((t) => t.id)
+    return allTools().map((t) => t.id)
   }
   }
 
 
   export async function tools(providerID: string, _modelID: string) {
   export async function tools(providerID: string, _modelID: string) {
     const result = await Promise.all(
     const result = await Promise.all(
-      ALL.map(async (t) => ({
+      allTools().map(async (t) => ({
         id: t.id,
         id: t.id,
         ...(await t.init()),
         ...(await t.init()),
       })),
       })),
@@ -45,21 +137,21 @@ export namespace ToolRegistry {
     if (providerID === "openai") {
     if (providerID === "openai") {
       return result.map((t) => ({
       return result.map((t) => ({
         ...t,
         ...t,
-        parameters: optionalToNullable(t.parameters),
+        parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
       }))
       }))
     }
     }
 
 
     if (providerID === "azure") {
     if (providerID === "azure") {
       return result.map((t) => ({
       return result.map((t) => ({
         ...t,
         ...t,
-        parameters: optionalToNullable(t.parameters),
+        parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
       }))
       }))
     }
     }
 
 
     if (providerID === "google") {
     if (providerID === "google") {
       return result.map((t) => ({
       return result.map((t) => ({
         ...t,
         ...t,
-        parameters: sanitizeGeminiParameters(t.parameters),
+        parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny),
       }))
       }))
     }
     }
 
 

+ 299 - 0
packages/opencode/test/tool/register.test.ts

@@ -0,0 +1,299 @@
+import "zod-openapi/extend"
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import os from "os"
+import { Instance } from "../../src/project/instance"
+
+// Helper to create a Request targeting the in-memory Hono app
+function makeRequest(method: string, url: string, body?: any) {
+  const headers: Record<string, string> = { "content-type": "application/json" }
+  const init: RequestInit = { method, headers }
+  if (body !== undefined) init.body = JSON.stringify(body)
+  return new Request(url, init)
+}
+
+describe("HTTP tool registration API", () => {
+  test("POST /tool/register then list via /tool/ids and /tool", async () => {
+    const projectRoot = path.join(__dirname, "../..")
+    await Instance.provide(projectRoot, async () => {
+      const { Server } = await import("../../src/server/server")
+
+      const toolSpec = {
+        id: "http-echo",
+        description: "Simple echo tool (test-only)",
+        parameters: {
+          type: "object" as const,
+          properties: {
+            foo: { type: "string" as const, optional: true },
+            bar: { type: "number" as const },
+          },
+        },
+        callbackUrl: "http://localhost:9999/echo",
+      }
+
+      // Register
+      const registerRes = await Server.App.fetch(
+        makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
+      )
+      expect(registerRes.status).toBe(200)
+      const ok = await registerRes.json()
+      expect(ok).toBe(true)
+
+      // IDs should include the new tool
+      const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
+      expect(idsRes.status).toBe(200)
+      const ids = (await idsRes.json()) as string[]
+      expect(ids).toContain("http-echo")
+
+      // List tools for a provider/model and check JSON Schema shape
+      const listRes = await Server.App.fetch(
+        makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
+      )
+      expect(listRes.status).toBe(200)
+      const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
+      const found = list.find((t) => t.id === "http-echo")
+      expect(found).toBeTruthy()
+      expect(found!.description).toBe("Simple echo tool (test-only)")
+
+      // Basic JSON Schema checks
+      expect(found!.parameters?.type).toBe("object")
+      expect(found!.parameters?.properties?.bar?.type).toBe("number")
+
+      const foo = found!.parameters?.properties?.foo
+      // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
+      const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
+      expect(fooIsNullable).toBe(true)
+    })
+  })
+})
+
+describe("Plugin tool.register hook", () => {
+  test("Plugin registers tool during Plugin.init()", async () => {
+    // Create a temporary project directory with opencode.json that points to our plugin
+    const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
+    await Bun.$`mkdir -p ${tmpDir}`
+
+    const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
+    const pluginCode = `
+      export async function TestPlugin() {
+        return {
+          async ["tool.register"](_input, { registerHTTP }) {
+            registerHTTP({
+              id: "from-plugin",
+              description: "Registered from test plugin",
+              parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
+              callbackUrl: "http://localhost:9999/echo"
+            })
+          }
+        }
+      }
+    `
+    await Bun.write(tmpPluginPath, pluginCode)
+
+    const configPath = path.join(tmpDir, "opencode.json")
+    await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
+
+    await Instance.provide(tmpDir, async () => {
+      const { Plugin } = await import("../../src/plugin")
+      const { ToolRegistry } = await import("../../src/tool/registry")
+      const { Server } = await import("../../src/server/server")
+
+      // Initialize plugins (will invoke our tool.register hook)
+      await Plugin.init()
+
+      // Confirm the tool is registered
+      const allIDs = ToolRegistry.ids()
+      expect(allIDs).toContain("from-plugin")
+
+      // Also verify via the HTTP surface
+      const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
+      expect(idsRes.status).toBe(200)
+      const ids = (await idsRes.json()) as string[]
+      expect(ids).toContain("from-plugin")
+    })
+  })
+})
+
+test("Multiple plugins can each register tools", async () => {
+  const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
+  await Bun.$`mkdir -p ${tmpDir}`
+
+  // Create two plugin files
+  const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
+  const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
+  const pluginA = `
+    export async function PluginA() {
+      return {
+        async ["tool.register"](_input, { registerHTTP }) {
+          registerHTTP({
+            id: "alpha-tool",
+            description: "Alpha tool",
+            parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
+            callbackUrl: "http://localhost:9999/echo"
+          })
+        }
+      }
+    }
+  `
+  const pluginB = `
+    export async function PluginB() {
+      return {
+        async ["tool.register"](_input, { registerHTTP }) {
+          registerHTTP({
+            id: "beta-tool",
+            description: "Beta tool",
+            parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
+            callbackUrl: "http://localhost:9999/echo"
+          })
+        }
+      }
+    }
+  `
+  await Bun.write(pluginAPath, pluginA)
+  await Bun.write(pluginBPath, pluginB)
+
+  // Config with both plugins
+  await Bun.write(
+    path.join(tmpDir, "opencode.json"),
+    JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
+  )
+
+  await Instance.provide(tmpDir, async () => {
+    const { Plugin } = await import("../../src/plugin")
+    const { ToolRegistry } = await import("../../src/tool/registry")
+    const { Server } = await import("../../src/server/server")
+
+    await Plugin.init()
+
+    const ids = ToolRegistry.ids()
+    expect(ids).toContain("alpha-tool")
+    expect(ids).toContain("beta-tool")
+
+    const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+    expect(res.status).toBe(200)
+    const httpIds = (await res.json()) as string[]
+    expect(httpIds).toContain("alpha-tool")
+    expect(httpIds).toContain("beta-tool")
+  })
+})
+
+test("Plugin registers native/local tool with function execution", async () => {
+  const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
+  await Bun.$`mkdir -p ${tmpDir}`
+
+  const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
+  const pluginCode = `
+    export async function NativeToolPlugin({ $, Tool, z }) {
+      // Use z (zod) provided by the plugin system
+      
+      // Define a native tool using Tool.define from plugin input
+      const MyNativeTool = Tool.define("my-native-tool", {
+        description: "A native tool that runs local code",
+        parameters: z.object({
+          message: z.string().describe("Message to process"),
+          count: z.number().optional().describe("Repeat count").default(1)
+        }),
+        async execute(args, ctx) {
+          // This runs locally in the plugin process, not via HTTP!
+          const result = args.message.repeat(args.count)
+          const output = \`Processed: \${result}\`
+          
+          // Can also run shell commands directly  
+          const hostname = await $\`hostname\`.text()
+          
+          return {
+            title: "Native Tool Result",
+            output: output + " on " + hostname.trim(),
+            metadata: { processedAt: new Date().toISOString() }
+          }
+        }
+      })
+      
+      return {
+        async ["tool.register"](_input, { register, registerHTTP }) {
+          // Register our native tool
+          register(MyNativeTool)
+          
+          // Can also register HTTP tools in the same plugin
+          registerHTTP({
+            id: "http-tool-from-same-plugin",
+            description: "HTTP tool alongside native tool",
+            parameters: { type: "object", properties: {} },
+            callbackUrl: "http://localhost:9999/echo"
+          })
+        }
+      }
+    }
+  `
+  await Bun.write(pluginPath, pluginCode)
+
+  await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
+
+  await Instance.provide(tmpDir, async () => {
+    const { Plugin } = await import("../../src/plugin")
+    const { ToolRegistry } = await import("../../src/tool/registry")
+    const { Server } = await import("../../src/server/server")
+
+    await Plugin.init()
+
+    // Both tools should be registered
+    const ids = ToolRegistry.ids()
+    expect(ids).toContain("my-native-tool")
+    expect(ids).toContain("http-tool-from-same-plugin")
+
+    // Verify via HTTP endpoint
+    const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+    expect(res.status).toBe(200)
+    const httpIds = (await res.json()) as string[]
+    expect(httpIds).toContain("my-native-tool")
+    expect(httpIds).toContain("http-tool-from-same-plugin")
+
+    // Get tool details to verify native tool has proper structure
+    const toolsRes = await Server.App.fetch(
+      new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
+    )
+    expect(toolsRes.status).toBe(200)
+    const tools = (await toolsRes.json()) as any[]
+    const nativeTool = tools.find((t) => t.id === "my-native-tool")
+    expect(nativeTool).toBeTruthy()
+    expect(nativeTool.description).toBe("A native tool that runs local code")
+    expect(nativeTool.parameters.properties.message).toBeTruthy()
+    expect(nativeTool.parameters.properties.count).toBeTruthy()
+  })
+})
+
+// Malformed plugin (no tool.register) should not throw and should not register anything
+test("Plugin without tool.register is handled gracefully", async () => {
+  const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
+  await Bun.$`mkdir -p ${tmpDir}`
+
+  const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
+  const pluginSrc = `
+    export async function NoRegisterPlugin() {
+      return {
+        // no tool.register hook provided
+        async config(_cfg) { /* noop */ }
+      }
+    }
+  `
+  await Bun.write(pluginPath, pluginSrc)
+
+  await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
+
+  await Instance.provide(tmpDir, async () => {
+    const { Plugin } = await import("../../src/plugin")
+    const { ToolRegistry } = await import("../../src/tool/registry")
+    const { Server } = await import("../../src/server/server")
+
+    await Plugin.init()
+
+    // Ensure our specific id isn't present
+    const ids = ToolRegistry.ids()
+    expect(ids).not.toContain("malformed-tool")
+
+    const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+    expect(res.status).toBe(200)
+    const httpIds = (await res.json()) as string[]
+    expect(httpIds).not.toContain("malformed-tool")
+  })
+})

+ 37 - 0
packages/plugin/src/index.ts

@@ -18,9 +18,34 @@ export type PluginInput = {
   directory: string
   directory: string
   worktree: string
   worktree: string
   $: BunShell
   $: BunShell
+  Tool: {
+    define(
+      id: string,
+      init: any | (() => Promise<any>)
+    ): any
+  }
+  z: any // Zod instance for creating schemas
 }
 }
 export type Plugin = (input: PluginInput) => Promise<Hooks>
 export type Plugin = (input: PluginInput) => Promise<Hooks>
 
 
+// Lightweight schema spec for HTTP-registered tools
+export type HttpParamSpec = {
+  type: "string" | "number" | "boolean" | "array"
+  description?: string
+  optional?: boolean
+  items?: "string" | "number" | "boolean"
+}
+export type HttpToolRegistration = {
+  id: string
+  description: string
+  parameters: {
+    type: "object"
+    properties: Record<string, HttpParamSpec>
+  }
+  callbackUrl: string
+  headers?: Record<string, string>
+}
+
 export interface Hooks {
 export interface Hooks {
   event?: (input: { event: Event }) => Promise<void>
   event?: (input: { event: Event }) => Promise<void>
   config?: (input: Config) => Promise<void>
   config?: (input: Config) => Promise<void>
@@ -99,4 +124,16 @@ export interface Hooks {
       metadata: any
       metadata: any
     },
     },
   ) => Promise<void>
   ) => Promise<void>
+  /**
+   * Allow plugins to register additional tools with the server.
+   * Use registerHTTP to add a tool that calls back to your plugin/service.
+   * Use register to add a native/local tool with direct function execution.
+   */
+  "tool.register"?: (
+    input: {},
+    output: {
+      registerHTTP: (tool: HttpToolRegistration) => void | Promise<void>
+      register: (tool: any) => void | Promise<void>  // Tool.Info type from opencode
+    },
+  ) => Promise<void>
 }
 }

+ 46 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -10,6 +10,15 @@ import type {
   EventSubscribeResponses,
   EventSubscribeResponses,
   ConfigGetData,
   ConfigGetData,
   ConfigGetResponses,
   ConfigGetResponses,
+  ToolRegisterData,
+  ToolRegisterResponses,
+  ToolRegisterErrors,
+  ToolIdsData,
+  ToolIdsResponses,
+  ToolIdsErrors,
+  ToolListData,
+  ToolListResponses,
+  ToolListErrors,
   PathGetData,
   PathGetData,
   PathGetResponses,
   PathGetResponses,
   SessionListData,
   SessionListData,
@@ -178,6 +187,42 @@ class Config extends _HeyApiClient {
   }
   }
 }
 }
 
 
+class Tool extends _HeyApiClient {
+  /**
+   * Register a new HTTP callback tool
+   */
+  public register<ThrowOnError extends boolean = false>(options?: Options<ToolRegisterData, ThrowOnError>) {
+    return (options?.client ?? this._client).post<ToolRegisterResponses, ToolRegisterErrors, ThrowOnError>({
+      url: "/experimental/tool/register",
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+      },
+    })
+  }
+
+  /**
+   * List all tool IDs (including built-in and dynamically registered)
+   */
+  public ids<ThrowOnError extends boolean = false>(options?: Options<ToolIdsData, ThrowOnError>) {
+    return (options?.client ?? this._client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
+      url: "/experimental/tool/ids",
+      ...options,
+    })
+  }
+
+  /**
+   * List tools with JSON schema parameters for a provider/model
+   */
+  public list<ThrowOnError extends boolean = false>(options: Options<ToolListData, ThrowOnError>) {
+    return (options.client ?? this._client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
+      url: "/experimental/tool",
+      ...options,
+    })
+  }
+}
+
 class Path extends _HeyApiClient {
 class Path extends _HeyApiClient {
   /**
   /**
    * Get the current path
    * Get the current path
@@ -649,6 +694,7 @@ export class OpencodeClient extends _HeyApiClient {
   project = new Project({ client: this._client })
   project = new Project({ client: this._client })
   event = new Event({ client: this._client })
   event = new Event({ client: this._client })
   config = new Config({ client: this._client })
   config = new Config({ client: this._client })
+  tool = new Tool({ client: this._client })
   path = new Path({ client: this._client })
   path = new Path({ client: this._client })
   session = new Session({ client: this._client })
   session = new Session({ client: this._client })
   command = new Command({ client: this._client })
   command = new Command({ client: this._client })

+ 121 - 6
packages/sdk/js/src/gen/types.gen.ts

@@ -1053,6 +1053,44 @@ export type McpRemoteConfig = {
 
 
 export type LayoutConfig = "auto" | "stretch"
 export type LayoutConfig = "auto" | "stretch"
 
 
+export type _Error = {
+  data: {
+    [key: string]: unknown
+  }
+}
+
+export type HttpToolRegistration = {
+  id: string
+  description: string
+  parameters: {
+    type: "object"
+    properties: {
+      [key: string]: HttpParamSpec
+    }
+  }
+  callbackUrl: string
+  headers?: {
+    [key: string]: string
+  }
+}
+
+export type HttpParamSpec = {
+  type: "string" | "number" | "boolean" | "array"
+  description?: string
+  optional?: boolean
+  items?: "string" | "number" | "boolean"
+}
+
+export type ToolIds = Array<string>
+
+export type ToolList = Array<ToolListItem>
+
+export type ToolListItem = {
+  id: string
+  description: string
+  parameters?: unknown
+}
+
 export type Path = {
 export type Path = {
   state: string
   state: string
   config: string
   config: string
@@ -1060,12 +1098,6 @@ export type Path = {
   directory: string
   directory: string
 }
 }
 
 
-export type _Error = {
-  data: {
-    [key: string]: unknown
-  }
-}
-
 export type TextPartInput = {
 export type TextPartInput = {
   id?: string
   id?: string
   type: "text"
   type: "text"
@@ -1276,6 +1308,89 @@ export type ConfigGetResponses = {
 
 
 export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]
 export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]
 
 
+export type ToolRegisterData = {
+  body?: HttpToolRegistration
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/experimental/tool/register"
+}
+
+export type ToolRegisterErrors = {
+  /**
+   * Bad request
+   */
+  400: _Error
+}
+
+export type ToolRegisterError = ToolRegisterErrors[keyof ToolRegisterErrors]
+
+export type ToolRegisterResponses = {
+  /**
+   * Tool registered successfully
+   */
+  200: boolean
+}
+
+export type ToolRegisterResponse = ToolRegisterResponses[keyof ToolRegisterResponses]
+
+export type ToolIdsData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/experimental/tool/ids"
+}
+
+export type ToolIdsErrors = {
+  /**
+   * Bad request
+   */
+  400: _Error
+}
+
+export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]
+
+export type ToolIdsResponses = {
+  /**
+   * Tool IDs
+   */
+  200: ToolIds
+}
+
+export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]
+
+export type ToolListData = {
+  body?: never
+  path?: never
+  query: {
+    directory?: string
+    provider: string
+    model: string
+  }
+  url: "/experimental/tool"
+}
+
+export type ToolListErrors = {
+  /**
+   * Bad request
+   */
+  400: _Error
+}
+
+export type ToolListError = ToolListErrors[keyof ToolListErrors]
+
+export type ToolListResponses = {
+  /**
+   * Tools
+   */
+  200: ToolList
+}
+
+export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
+
 export type PathGetData = {
 export type PathGetData = {
   body?: never
   body?: never
   path?: never
   path?: never