Просмотр исходного кода

perf(core): load MCP client lazily (#42468)

Kit Langton 3 дней назад
Родитель
Сommit
fec4f20736
2 измененных файлов с 52 добавлено и 3 удалено
  1. 8 3
      packages/core/src/mcp/index.ts
  2. 44 0
      packages/core/test/mcp-import-boundary.test.ts

+ 8 - 3
packages/core/src/mcp/index.ts

@@ -19,8 +19,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
 import { Location } from "../location.js"
 import { waitForAbort } from "@opencode-ai/util/process"
 import { State } from "../state.js"
-import { MCPClient } from "./client.js"
-import { MCPOAuth } from "./oauth.js"
+import type { MCPClient } from "./client.js"
 
 export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
 export type ServerName = typeof ServerName.Type
@@ -245,7 +244,11 @@ export const layer = (options?: Options) =>
             draft.method.update({
               integrationID,
               method: { id: methodID, type: "oauth", label: name },
-              authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
+              authorize: () =>
+                Effect.gen(function* () {
+                  const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
+                  return yield* MCPOAuth.authorize({ name, config: remote, methodID })
+                }),
             })
           })
           .pipe(Scope.provide(scope))
@@ -264,6 +267,7 @@ export const layer = (options?: Options) =>
       // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
       const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
         if (entry.config.type !== "remote" || !entry.integrationID) return undefined
+        const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
         const remote = entry.config
         const oauth = remote.oauth || undefined
         const base = {
@@ -505,6 +509,7 @@ export const layer = (options?: Options) =>
           const scope = yield* Scope.fork(root)
           entry.scope = scope
           const authProvider = yield* connectProvider(entry)
+          const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
           // List tools as part of connect so a failure here marks the server failed rather than
           // leaving it connected with a silently empty tool list and no path to recover.
           const result = yield* MCPClient.connect(

+ 44 - 0
packages/core/test/mcp-import-boundary.test.ts

@@ -0,0 +1,44 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import path from "node:path"
+
+const root = path.resolve(import.meta.dir, "../../..")
+
+test("loads the MCP SDK only when connecting or authorizing", async () => {
+  const temporary = await mkdtemp(path.join(import.meta.dir, ".mcp-import-boundary-"))
+  const metafile = path.join(temporary, "meta.json")
+  try {
+    const result = Bun.spawn(
+      [
+        process.execPath,
+        "build",
+        "packages/core/src/mcp/index.ts",
+        "--target=node",
+        "--format=esm",
+        "--packages=bundle",
+        "--splitting",
+        `--metafile=${metafile}`,
+        `--outdir=${path.join(temporary, "out")}`,
+      ],
+      { cwd: root, stdout: "pipe", stderr: "pipe" },
+    )
+    const [exitCode, stdout, stderr] = await Promise.all([
+      result.exited,
+      new Response(result.stdout).text(),
+      new Response(result.stderr).text(),
+    ])
+    if (exitCode !== 0) throw new Error(stdout + stderr)
+
+    const metadata = await Bun.file(metafile).json()
+    const imports = metadata.inputs["packages/core/src/mcp/index.ts"].imports
+    const lazy = imports.filter(
+      (item: { original?: string }) => item.original === "./client.js" || item.original === "./oauth.js",
+    )
+    expect(new Set(lazy.map((item: { original: string }) => item.original))).toEqual(
+      new Set(["./client.js", "./oauth.js"]),
+    )
+    expect(lazy.every((item: { kind: string }) => item.kind === "dynamic-import")).toBe(true)
+  } finally {
+    await rm(temporary, { recursive: true, force: true })
+  }
+})