Ctrl K
ComunidadGuías…

packages/client/src/mcp.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
export type ClientMcpOptions = {
  /** Base URL of the opencode server, for example `http://127.0.0.1:4096`. Its query params are kept. */
  readonly url: string
  /** Client name, `[a-zA-Z0-9_-]+`. Tools are exposed to the model as `<name>_<tool>`. */
  readonly name: string
  /** Sent as `location[directory]`. */
  readonly directory?: string
  /** Handshake headers, such as `authorization`. Browsers cannot send them; use a ticket in `url` instead. */
  readonly headers?: Record<string, string>
  readonly tools: ReadonlyArray<ClientMcpTool>
  readonly WebSocket?: typeof WebSocket
}

export type ClientMcpTool = {
  /** `[a-zA-Z0-9_-]+` */
  readonly name: string
  readonly description?: string
  /** JSON Schema for the arguments; `type: "object"` is always set. Arguments are not validated here. */
  readonly inputSchema?: Record<string, unknown>
  readonly handler: (
    args: Record<string, unknown>,
    ctx: ClientMcpToolContext,
  ) => ClientMcpToolResult | Promise<ClientMcpToolResult>
}

export type ClientMcpToolContext = {
  /** The opencode session that issued the call. */
  readonly sessionID?: string
}

export type ClientMcpToolResult = {
  readonly content: ReadonlyArray<
    | { readonly type: "text"; readonly text: string }
    | { readonly type: "image"; readonly data: string; readonly mimeType: string }
  >
  readonly isError?: boolean
}

export type ClientMcpConnection = {
  close(): Promise<void>
}

type Message = {
  readonly id?: unknown
  readonly method?: unknown
  readonly params?: {
    readonly protocolVersion?: unknown
    readonly name?: unknown
    readonly arguments?: Record<string, unknown>
    readonly _meta?: Record<string, unknown>
  }
}

const SESSION_ID_META_KEY = "ai.opencode/sessionID"
const PROTOCOL_VERSION = "2025-11-25"
const RECONNECT_MIN = 500
const RECONNECT_MAX = 10_000

/**
 * Exposes tools to the opencode server: opens a WebSocket to `/api/experimental/client-mcp` and answers MCP
 * JSON-RPC over it (one message per text frame), so the opencode server is the MCP client. Reconnects with
 * backoff until `close()`.
 */
export function connectClientMcp(options: ClientMcpOptions): ClientMcpConnection {
  const state: { disposed: boolean; delay: number; timer?: ReturnType<typeof setTimeout>; socket?: WebSocket } = {
    disposed: false,
    delay: RECONNECT_MIN,
  }

  const connect = () => {
    if (state.disposed) return
    const Socket = options.WebSocket ?? WebSocket
    const socket = options.headers
      ? // Bun and Node accept a `{ headers }` init as the second argument; the DOM typings only know protocols.
        new Socket(endpoint(options), { headers: options.headers } as unknown as string[])
      : new Socket(endpoint(options))
    state.socket = socket
    socket.addEventListener("message", (event) => {
      if (state.disposed || typeof event.data !== "string") return
      const message = parse(event.data)
      if (!message) return
      // Reset backoff only once the opencode server finished initializing, so a failed attach keeps backing off.
      if (message.method === "notifications/initialized") state.delay = RECONNECT_MIN
      // Notifications and responses need no reply.
      if ((typeof message.id !== "string" && typeof message.id !== "number") || typeof message.method !== "string")
        return
      void respond(options, message.method, message.params).then((reply) =>
        send(socket, { jsonrpc: "2.0", id: message.id, ...reply }),
      )
    })
    socket.addEventListener("close", () => {
      if (state.socket === socket) state.socket = undefined
      if (state.disposed) return
      state.timer = setTimeout(connect, state.delay)
      state.delay = Math.min(state.delay * 2, RECONNECT_MAX)
    })
  }

  connect()

  return {
    async close() {
      state.disposed = true
      clearTimeout(state.timer)
      state.socket?.close(1000)
    },
  }
}

async function respond(options: ClientMcpOptions, method: string, params: Message["params"]) {
  switch (method) {
    case "initialize":
      return {
        result: {
          // The opencode server only offers versions it supports, so echo its choice.
          protocolVersion: typeof params?.protocolVersion === "string" ? params.protocolVersion : PROTOCOL_VERSION,
          capabilities: { tools: {} },
          serverInfo: { name: options.name, version: "1.0.0" },
        },
      }
    case "ping":
      return { result: {} }
    case "tools/list":
      return {
        result: {
          tools: options.tools.map((tool) => ({
            name: tool.name,
            description: tool.description,
            inputSchema: { type: "object", ...tool.inputSchema },
          })),
        },
      }
    case "tools/call": {
      const tool = options.tools.find((tool) => tool.name === params?.name)
      if (!tool) return { error: { code: -32602, message: `Unknown tool: ${String(params?.name)}` } }
      const meta = params?._meta?.[SESSION_ID_META_KEY]
      return Promise.resolve()
        .then(() => tool.handler(params?.arguments ?? {}, { sessionID: typeof meta === "string" ? meta : undefined }))
        .then(
          (result) => ({ result }),
          (error) => ({
            result: {
              content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
              isError: true,
            },
          }),
        )
    }
  }
  return { error: { code: -32601, message: `Method not found: ${method}` } }
}

function send(socket: WebSocket, message: object) {
  if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message))
}

function parse(text: string): Message | undefined {
  try {
    const value: unknown = JSON.parse(text)
    return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined
  } catch {
    return undefined
  }
}

function endpoint(options: ClientMcpOptions) {
  const base = new URL(options.url)
  if (!base.pathname.endsWith("/")) base.pathname += "/"
  const url = new URL("api/experimental/client-mcp", base)
  url.search = base.search
  if (options.directory) url.searchParams.set("location[directory]", options.directory)
  url.searchParams.set("name", options.name)
  url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:"
  return url
}