Dax Raad 8 месяцев назад
Родитель
Сommit
37c34fd39c

+ 8 - 0
packages/opencode/opencode.json

@@ -0,0 +1,8 @@
+{
+  "mcp": {
+    "planetscale": {
+      "type": "local",
+      "command": ["pscale", "mcp", "server"]
+    }
+  }
+}

+ 39 - 27
packages/opencode/src/config/config.ts

@@ -1,17 +1,53 @@
-import path from "path"
 import { Log } from "../util/log"
 import { z } from "zod"
 import { App } from "../app/app"
 import { Provider } from "../provider/provider"
+import { Filesystem } from "../util/filesystem"
 
 export namespace Config {
   const log = Log.create({ service: "config" })
 
   export const state = App.state("config", async (app) => {
-    const result = await load(app.path.root)
+    let result: Info = {}
+    for (const file of ["opencode.jsonc", "opencode.json"]) {
+      const resolved = await Filesystem.findUp(
+        file,
+        app.path.cwd,
+        app.path.root,
+      )
+      if (!resolved) continue
+      try {
+        result = await import(resolved).then((mod) => Info.parse(mod.default))
+        log.info("found", { path: resolved })
+        break
+      } catch (e) {
+        if (e instanceof z.ZodError) {
+          for (const issue of e.issues) {
+            log.info(issue.message)
+          }
+          throw e
+        }
+        continue
+      }
+    }
+    log.info("loaded", result)
     return result
   })
 
+  export const McpLocal = z.object({
+    type: z.literal("local"),
+    command: z.string().array(),
+    environment: z.record(z.string(), z.string()).optional(),
+  })
+
+  export const McpRemote = z.object({
+    type: z.literal("remote"),
+    url: z.string(),
+  })
+
+  export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
+  export type Mcp = z.infer<typeof Mcp>
+
   export const Info = z
     .object({
       provider: z.lazy(() => Provider.Info.array().optional()),
@@ -20,6 +56,7 @@ export namespace Config {
           provider: z.record(z.string(), z.string().array()).optional(),
         })
         .optional(),
+      mcp: z.record(z.string(), Mcp).optional(),
     })
     .strict()
 
@@ -28,29 +65,4 @@ export namespace Config {
   export function get() {
     return state()
   }
-
-  async function load(directory: string) {
-    let result: Info = {}
-    for (const file of ["opencode.jsonc", "opencode.json"]) {
-      const resolved = path.join(directory, file)
-      log.info("searching", { path: resolved })
-      try {
-        result = await import(path.join(directory, file)).then((mod) =>
-          Info.parse(mod.default),
-        )
-        log.info("found", { path: resolved })
-        break
-      } catch (e) {
-        if (e instanceof z.ZodError) {
-          for (const issue of e.issues) {
-            log.info(issue.message)
-          }
-          throw e
-        }
-        continue
-      }
-    }
-    log.info("loaded", result)
-    return result
-  }
 }

+ 63 - 0
packages/opencode/src/mcp/index.ts

@@ -0,0 +1,63 @@
+import { experimental_createMCPClient, type Tool } from "ai"
+import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"
+import { App } from "../app/app"
+import { Config } from "../config/config"
+
+export namespace MCP {
+  const state = App.state(
+    "mcp",
+    async () => {
+      const cfg = await Config.get()
+      const clients: {
+        [name: string]: Awaited<ReturnType<typeof experimental_createMCPClient>>
+      } = {}
+      for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
+        if (mcp.type === "remote") {
+          clients[key] = await experimental_createMCPClient({
+            name: key,
+            transport: {
+              type: "sse",
+              url: mcp.url,
+            },
+          })
+        }
+
+        if (mcp.type === "local") {
+          const [cmd, ...args] = mcp.command
+          clients[key] = await experimental_createMCPClient({
+            name: key,
+            transport: new Experimental_StdioMCPTransport({
+              stderr: "ignore",
+              command: cmd,
+              args,
+              env: mcp.environment,
+            }),
+          })
+        }
+      }
+
+      return {
+        clients,
+      }
+    },
+    async (state) => {
+      for (const client of Object.values(state.clients)) {
+        client.close()
+      }
+    },
+  )
+
+  export async function clients() {
+    return state().then((state) => state.clients)
+  }
+
+  export async function tools() {
+    const result: Record<string, Tool> = {}
+    for (const [clientName, client] of Object.entries(await clients())) {
+      for (const [toolName, tool] of Object.entries(await client.tools())) {
+        result[clientName + "_" + toolName] = tool
+      }
+    }
+    return result
+  }
+}

+ 37 - 1
packages/opencode/src/session/session.ts

@@ -26,6 +26,7 @@ import { Bus } from "../bus"
 import { Provider } from "../provider/provider"
 import { SessionContext } from "./context"
 import { ListTool } from "../tool/ls"
+import { MCP } from "../mcp"
 
 export namespace Session {
   const log = Log.create({ service: "session" })
@@ -342,6 +343,38 @@ ${app.git ? await ListTool.execute({ path: app.path.cwd }, { sessionID: input.se
         },
       })
     }
+    for (const [key, item] of Object.entries(await MCP.tools())) {
+      const execute = item.execute
+      if (!execute) continue
+      item.execute = async (args, opts) => {
+        const start = Date.now()
+        try {
+          const result = await execute(args, opts)
+          next.metadata!.tool![opts.toolCallId] = {
+            ...result.metadata,
+            time: {
+              start,
+              end: Date.now(),
+            },
+          }
+          return result.content
+            .filter((x: any) => x.type === "text")
+            .map((x: any) => x.text)
+            .join("\n\n")
+        } catch (e: any) {
+          next.metadata!.tool![opts.toolCallId] = {
+            error: true,
+            message: e.toString(),
+            time: {
+              start,
+              end: Date.now(),
+            },
+          }
+          return e.toString()
+        }
+      }
+      tools[key] = item
+    }
     const result = streamText({
       onStepFinish: async (step) => {
         const assistant = next.metadata!.assistant!
@@ -356,7 +389,10 @@ ${app.git ? await ListTool.execute({ path: app.path.cwd }, { sessionID: input.se
       stopWhen: stepCountIs(1000),
       messages: convertToModelMessages(msgs),
       temperature: 0,
-      tools,
+      tools: {
+        ...(await MCP.tools()),
+        ...tools,
+      },
       model: model.language,
     })
     let text: Message.TextPart | undefined

+ 2 - 2
packages/opencode/src/util/filesystem.ts

@@ -2,11 +2,12 @@ import { exists } from "fs/promises"
 import { dirname, join } from "path"
 
 export namespace Filesystem {
-  export async function findUp(target: string, start: string) {
+  export async function findUp(target: string, start: string, stop?: string) {
     let currentDir = start
     while (true) {
       const targetPath = join(currentDir, target)
       if (await exists(targetPath)) return targetPath
+      if (stop === currentDir) return
       const parentDir = dirname(currentDir)
       if (parentDir === currentDir) {
         return
@@ -15,4 +16,3 @@ export namespace Filesystem {
     }
   }
 }
-