Skip to content

First prompt after start has no MCP tools: tool list does not wait for pending MCP connections #51812

Description

@philosophissima

Description

After opencode serve starts (or on opencode run --standalone), the first prompt sent for a directory is built without any MCP tools. From the second prompt on, the MCP tools are present.

As far as I can tell, the per-directory instance is booted lazily on first access and MCP servers connect in the background. Building the tool list (MCP.tools()) does not wait for connections that are still pending (unlike callTool), so the first request only contains built-in tools.

For opencode run --standalone this means a one-shot run never gets MCP tools, since there is no second prompt.

Steps to reproduce

  1. Configure one or more remote MCP servers (type: remote, codemode: false).
  2. opencode serve (fresh start), then send the first prompt for a directory, or run opencode run --standalone "...".
  3. Inspect the request sent to the model: no MCP tools. GET /api/mcp right before the prompt shows the servers as pending.
  4. Send a second prompt: MCP tools are included.

Expected

The first prompt waits (with a timeout) for MCP servers that are still connecting, or the instance connects MCP eagerly on boot, so the first request gets the same tool set as later ones.

Workaround

Poll GET /api/mcp (with x-opencode-directory) after start until no server is pending, then send the prompt. For run, start a private opencode serve, wait the same way, then opencode run --server <url>.

Environment

Activity

  1. github-actions commented on Sep 28, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or related to an existing issue. Please check:

    • #51089: A prompt immediately after mcp.add can run before the connected server’s tools are reconciled into the session registry, so MCP tools are also missing from the first model request. That issue concerns runtime MCP additions; this report concerns servers still connecting at startup.
  2. r266-tech commented on Sep 28, 2026

    @r266-tech
    Contributor

    I reproduced the initial-read boundary with the real Mcp and McpTool layers at v2.0.18 (cd9a14a), using the existing local HTTP MCP fixture and a Deferred gate on its POST handshake. No model or external MCP server was involved.

    The test observes, in order:

    1. The server has received the handshake but the gate is still closed.
    2. McpTool.flush returns successfully.
    3. Mcp.servers() still reports pending, and the Tool registry has no resources_echo definition.
    4. After releasing the gate and waiting for connection settlement, Mcp.tools() includes echo.

    This passes as a characterization test (1 test, 3 assertions). It exercises the readiness boundary used by SessionContext.select, not a complete serve/run session.

    The source path is: initial connections deliberately fork in Mcp.reconcile; Mcp.tools() returns the current catalog without waiting; the initial registration fiber in tool/mcp.ts therefore finishes with an empty catalog. flush awaits that fiber, and SessionContext.select then takes its snapshot. The relevant logic is unchanged on current v2 39e1ce55.

    This appears distinct from #51089's post-mcp.add debounce window: here the connection itself is still pending. A readiness fix should retain asynchronous Location startup while deciding what the first session snapshot waits for, including failed/disabled/cancelled servers. The draft #47389 also touches this boundary, so I am leaving this scoped reproduction here for the assigned maintainer rather than opening a competing patch.

  3. rekram1-node commented on Sep 29, 2026

    @rekram1-node
    Contributor

    Yeah this was intentional, will think about it more

  4. nkoynov commented on Oct 5, 2026

    @nkoynov

    Our case for changing this: T3 Code starts opencode serve, adds a per-thread MCP server with PUT /api/experimental/mcp/:server, and sends the first prompt right away. Some providers fix the tool list for the whole turn (cursor-opencode-provider does), so turn 1 never sees that server's tools. Here's a proposal that keeps startup async: on a session's first model request only, wait for servers that are still pending, up to a short limit (say 3 s, configurable), then go on with whatever has connected. Later requests never wait, and one slow server can't hold up Location startup. We do this today in a plugin on 2.0.22 (session.hook("prompt") holds the first prompt, with a cap, until the servers are registered): the wait was about 100 ms on a new thread and 0 ms on later turns. The mcp.add side of the same race is #51089 / PR #52214.

  5. epulla commented on Oct 14, 2026

    @epulla

    Waiting until /api/mcp shows no pending servers (the workaround above) isn't enough on 2.0.28. A server can show connected and still be missing from the first turn's tools for ~100ms. Probably the same debounce as #53299 / #51089, but over opencode serve instead of ACP.

    Repro: opencode serve with two local MCP servers, one of which starts 2s late. Wait until both show connected, then ask the model which tools it has:

    Prompt sent after both connected Tools the model saw
    immediately fast only (slow missing)
    1.5s later fast and slow

    Same in 2/2 runs, and in 3 real sessions with the Atlassian MCP server. Our workaround: wait 1s after everything shows connected.

  6. nkoynov commented on Oct 14, 2026

    @nkoynov

    Same here. Our local workaround (a plugin that holds the first prompt) ended up waiting until each connected server's tools show up in the tool registry, not just until its status is connected, because of exactly that gap. So if the bounded wait I suggested above gets picked up, it should key off the registry rather than the status.

  7. geoff-ux commented on Oct 16, 2026

    @geoff-ux

    This still reproduces on 2.0.29 and 2.0.31 with a self-contained repro. The MCP server is @modelcontextprotocol/server-everything@1.0.0, started as sh -c "sleep 10; exec npx -y @modelcontextprotocol/server-everything@1.0.0" (local, codemode: false). The client is plain curl against opencode serve, with fresh XDG dirs and anthropic/claude-haiku-5-5.

    Prompt: "Call the echo tool with the message hi, then reply with what it returned."

    Version Turn GET /api/mcp at prompt Prompt tokens (1st request) echo called
    2.0.29 / 2.0.31 first turn, ~0.35 s after /api/info [] 8,077 no ("none is available in this session")
    2.0.29 / 2.0.31 first turn, held until the server shows pending pending 8,092 no
    2.0.29 / 2.0.31 new session, 2 s after connected connected 11,600 yes, everything_echo returns "Echo: hi"

    One more data point for the polling workaround: for the first 0.3–0.45 s after the first request for a directory, GET /api/mcp returns []. The configured server is not listed even as pending, so polling until no server is pending can pass before the server is registered at all. To be safe, wait until each configured server is listed and connected, and its tools are in the registry.

    The source path is unchanged on v2.0.31 and dev (180fd9d): Mcp.tools() at mcp/index.ts L663–L668 does not await startup, McpTool.flush at tool/mcp.ts L144 waits only for the initial registration, and SessionContext.select at session/context.ts L127–L134 snapshots right after it. PR #54942 would not change this, because it re-reads Mcp.tools(), which still leaves pending servers out.

  8. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report, I can reproduce this.

    With a mock remote MCP server that takes ~1.5 s to answer initialize (codemode: false) and a mock OpenAI-compatible provider that logs the tools in each request, on 2.0.18:

    • opencode run --standalone "...": the model requests only ever contain the built-in tools. The run finishes before the MCP server connects, so a one-shot run never gets MCP tools.
    • A fresh opencode serve, then prompts via opencode run --server:
      • The first prompt has no MCP tools, and GET /api/mcp shows the server as pending.
      • Later prompts include the MCP tool.
      • One extra finding: on 2.0.18 the second prompt was still missing the tools even though I waited until GET /api/mcp reported connected (the mock server got a second initialize around then). So polling /api/mcp may not be a fully reliable workaround.

    On the latest dev (86ce19d), the first prompt after opencode serve still has no MCP tools. I couldn't check run --standalone on dev from source because the standalone server didn't start there.

    The script below reproduces both cases. Related, but different problems: #45333 and #38470.

    Reproduction script (repro.ts)
    // Reproduction for anomalyco/opencode#51812: the first prompt after start has no MCP tools.
    // Starts a mock OpenAI-compatible provider (logs the tool names in each request) and a mock remote MCP
    // server whose `initialize` takes MCP_DELAY ms, then runs `opencode run --standalone` once.
    // Usage: bun repro.ts            (uses `opencode` on PATH)
    //        OC_CMD="bun ... /workspace/repo/packages/cli/src/index.ts" bun repro.ts   (from source)
    import { mkdtempSync, writeFileSync } from "node:fs"
    import { tmpdir } from "node:os"
    import { join } from "node:path"
    
    const DELAY = Number(process.env.MCP_DELAY ?? 1500)
    const requests: string[][] = []
    
    const llm = Bun.serve({
      port: 0,
      async fetch(req) {
        const url = new URL(req.url)
        if (!url.pathname.endsWith("/chat/completions")) return new Response("{}", { status: 404 })
        const body = await req.json()
        const names = (body.tools ?? []).map((t: any) => t.function?.name ?? t.name)
        // Code Mode lists MCP tools inside the `execute` tool description; count those too.
        if (JSON.stringify(body.tools ?? []).includes("echo_probe") && !names.includes("echo_probe")) names.push("echo_probe(in execute)")
        requests.push(names)
        console.log(`[llm] request #${requests.length}: ${names.length} tools: ${names.join(", ")}`)
        const id = "chatcmpl-1"
        const chunk = (delta: any, finish: string | null = null) =>
          `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: 0, model: "m", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`
        // Answer the first agent step with a tool call, so a second step shows the tool set once MCP has connected.
        const agentSteps = requests.filter((r) => r.length > 0).length
        // Hold the first step long enough for the MCP server to finish connecting meanwhile.
        
        const sse =
          (names.length > 0 && agentSteps === 1
            ? chunk({ role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "glob", arguments: JSON.stringify({ pattern: "*.json" }) } }] }) +
              chunk({}, "tool_calls")
            : chunk({ role: "assistant", content: "hello" }) + chunk({}, "stop")) +
          `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: 0, model: "m", choices: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
          "data: [DONE]\n\n"
        return new Response(sse, { headers: { "content-type": "text/event-stream" } })
      },
    })
    
    const mcp = Bun.serve({
      port: 0,
      async fetch(req) {
        if (req.method !== "POST") return new Response(null, { status: 405 })
        const msg = await req.json()
        if (msg.id === undefined) return new Response(null, { status: 202 })
        console.log(`[mcp] ${msg.method}`)
        let result: any = {}
        if (msg.method === "initialize") {
          await Bun.sleep(DELAY)
          result = { protocolVersion: msg.params?.protocolVersion ?? "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "slow", version: "1.0.0" } }
        } else if (msg.method === "tools/list") {
          result = { tools: [{ name: "echo_probe", description: "Echo", inputSchema: { type: "object", properties: { text: { type: "string" } } } }] }
        }
        return Response.json({ jsonrpc: "2.0", id: msg.id, result }, { headers: { "mcp-session-id": "s1" } })
      },
    })
    
    const dir = mkdtempSync(join(tmpdir(), "oc-51812-"))
    writeFileSync(
      join(dir, "opencode.json"),
      JSON.stringify(
        {
          $schema: "https://opencode.ai/config.json",
          model: "mock/m",
          provider: {
            mock: { npm: "@ai-sdk/openai-compatible", name: "Mock", options: { baseURL: `http://127.0.0.1:${llm.port}/v1`, apiKey: "x" }, models: { m: { name: "m" } } },
          },
          mcp: { servers: { slow: { type: "remote", url: `http://127.0.0.1:${mcp.port}/mcp`, codemode: false } } },
        },
        null,
        2,
      ),
    )
    
    import { mkdirSync, copyFileSync } from "node:fs"
    mkdirSync(join(dir, ".config/opencode"), { recursive: true })
    copyFileSync(join(dir, "opencode.json"), join(dir, ".config/opencode/opencode.json"))
    Bun.spawnSync(["git", "init", "-q"], { cwd: dir })
    const cmd = (process.env.OC_CMD ?? "opencode").split(" ")
    const env = { ...process.env, OPENCODE_PASSWORD: "repro", HOME: dir, XDG_CONFIG_HOME: join(dir, ".config"), XDG_DATA_HOME: join(dir, ".data"), XDG_STATE_HOME: join(dir, ".state"), XDG_CACHE_HOME: join(dir, ".cache") }
    const run = async (args: string[]) => {
      const proc = Bun.spawn([...cmd, ...args], { cwd: dir, stdout: "pipe", stderr: "pipe", env })
      const code = await proc.exited
      console.log(`[opencode ${args.slice(0, 2).join(" ")}] exit ${code}`, (await new Response(proc.stderr).text()).trim().slice(-800))
    }
    
    // Part 1: one-shot `opencode run --standalone`.
    console.log("=== opencode run --standalone ===")
    await run(["run", "--standalone", "say hello"])
    const standalone = requests.splice(0)
    
    // Part 2: `opencode serve`, then two prompts against it (first sent right after the server is up).
    console.log("=== opencode serve + two prompts ===")
    const port = 40000 + Math.floor(Math.random() * 10000)
    const server = Bun.spawn([...cmd, "serve", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: dir, stdout: "ignore", stderr: "ignore", env })
    for (let i = 0; i < 100; i++) {
      const ok = await fetch(`http://127.0.0.1:${port}/api/mcp`, { headers: { "x-opencode-directory": dir, authorization: "Basic " + btoa("opencode:repro") } }).then((r) => r.text()).catch(() => undefined)
      if (ok !== undefined) { console.log("[serve] up; GET /api/mcp:", ok.slice(0, 300)); break }
      await Bun.sleep(100)
    }
    await run(["run", "--server", `http://127.0.0.1:${port}`, "first prompt"])
    const p1 = requests.splice(0)
    console.log("[serve] GET /api/mcp after first prompt:", (await fetch(`http://127.0.0.1:${port}/api/mcp`, { headers: { "x-opencode-directory": dir, authorization: "Basic " + btoa("opencode:repro") } }).then((r) => r.text())).slice(0, 300))
    // Wait (as the author's workaround does) until no MCP server is pending, then send the second prompt.
    for (let i = 0; i < 100; i++) {
      const text = await fetch(`http://127.0.0.1:${port}/api/mcp`, { headers: { "x-opencode-directory": dir, authorization: "Basic " + btoa("opencode:repro") } }).then((r) => r.text())
      if (!text.includes('"pending"')) { console.log("[serve] GET /api/mcp before second prompt:", text.slice(0, 300)); break }
      await Bun.sleep(100)
    }
    await run(["run", "--server", `http://127.0.0.1:${port}`, "second prompt"])
    const p2 = requests.splice(0)
    await Bun.sleep(DELAY + 3000)
    console.log("[serve] GET /api/mcp before third prompt:", (await fetch(`http://127.0.0.1:${port}/api/mcp`, { headers: { "x-opencode-directory": dir, authorization: "Basic " + btoa("opencode:repro") } }).then((r) => r.text())).slice(0, 300))
    await run(["run", "--server", `http://127.0.0.1:${port}`, "third prompt"])
    const p3 = requests.splice(0)
    server.kill()
    
    const has = (reqs: string[][]) => reqs.filter((r) => r.length > 0).some((r) => r.some((n) => n.startsWith("echo_probe")))
    console.log(`RESULT standalone run: MCP tool ${has(standalone) ? "present" : "MISSING"}`)
    console.log(`RESULT serve, first prompt: MCP tool ${has(p1) ? "present" : "MISSING"}`)
    console.log(`RESULT serve, second prompt: MCP tool ${has(p2) ? "present" : "MISSING"}`)
    console.log(`RESULT serve, third prompt: MCP tool ${has(p3) ? "present" : "MISSING"}`)
    llm.stop(true)
    mcp.stop(true)

    Run it: bun repro.ts

  9. cktang88 commented on Oct 30, 2026

    @cktang88

    also ran into this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions