Skip to content

MCP prompts/list failure wipes the cached prompt list and announces it as empty #50918

Closed

Description

@feiiiiii5

Description

refreshPrompts turns a failed prompts/list into an empty list and then commits it: Effect.orElseSucceed(() => []) at packages/core/src/mcp/index.ts:307, and the Effect.map below it assigns entry.prompts from that empty result before publishing PromptsChanged (:305-312). refreshTools directly above (:298-303) has no such swallow, so a failed tools refresh leaves the registered tools alone. The two listings behave differently on the same failure.

So one transient error on a server that is still connected wipes its prompts, and the event that follows tells every reader the list is now legitimately empty — packages/core/src/plugin/command.ts:18-24 reacts to PromptsChanged by re-reading mcp.prompts() and calling ctx.command.reload(). The server's slash commands disappear and nothing indicates why: servers() still reports status: "connected".

Plugins

None.

OpenCode version

v2 at 681ea07a9.

Steps to reproduce

Using the resourceServer fixture in packages/core/test/mcp.test.ts, with prompts capability advertising listChanged and a responder that answers prompts/list with a JSON-RPC error once armed:

  1. Connect; the initial listing succeeds and service.prompts() contains greet.
  2. The server sends prompts/list_changed; the client re-lists and the server answers with the error.
  3. service.prompts() is now [], while service.servers() still reports { status: "connected" }.

Expected: the failed refresh keeps the prompts it already had, as refreshTools does. The regression test in the PR fails on 681ea07a9 with prompts were [] and passes with the one-line change.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I can confirm this on the latest dev (fa1260d).

    To reproduce it, I changed the resourceServer test fixture so its prompts capability advertises listChanged: true and prompts/list can be made to fail on demand. Then I connected through Mcp.Service and waited until service.prompts() returned ["greet"]. Next I made prompts/list fail and sent notifications/prompts/list_changed:

    • service.prompts() became [].
    • service.servers() still reported { status: "connected" }.

    When I removed Effect.orElseSucceed(() => []) from refreshPrompts, the failed refresh kept greet, which matches how refreshTools behaves. The script below reproduces it against a source checkout.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#50918: a failed MCP prompts/list refresh wipes the server's prompts
    # while the server stays "connected".
    # Usage: REPO=/path/to/opencode-checkout bash repro.sh   (needs `bun install` done in the checkout)
    set -euo pipefail
    REPO=${REPO:-/workspace/repo}
    cd "$REPO/packages/core"
    TEST=test/mcp.test.ts
    cp "$TEST" /tmp/mcp.test.ts.orig
    trap 'cp /tmp/mcp.test.ts.orig "$REPO/packages/core/$TEST"' EXIT
    
    # 1. Let the resourceServer fixture fail prompts/list on demand and send prompts/list_changed.
    python3 - "$TEST" <<'PY'
    import sys
    p = sys.argv[1]
    s = open(p).read()
    s = s.replace("""        sessions: [] as string[],
          }""", """        sessions: [] as string[],
            failPrompts: false,
            promptLists: 0,
          }""", 1)
    s = s.replace("""protocol.setRequestHandler("prompts/list", () => Promise.resolve({ prompts: [{ name: "greet" }] }))""",
    """protocol.setRequestHandler("prompts/list", () =>
              ++state.promptLists && state.failPrompts
                ? Promise.reject(new Error("prompts/list temporarily unavailable"))
                : Promise.resolve({ prompts: [{ name: "greet" }] }),
            )""", 1)
    s = s.replace("""              prompts: {},""", """              prompts: { listChanged: true },""", 1)
    s = s.replace("""        completeElicitation: () =>""", """        sendPromptListChanged: () => current.protocol.sendPromptListChanged(),
            completeElicitation: () =>""", 1)
    s += '''
    
    test("repro #50918: failed prompts/list refresh keeps existing prompts", async () => {
      await Effect.runPromise(
        Effect.scoped(
          Effect.gen(function* () {
            const server = yield* resourceServer()
            yield* Effect.gen(function* () {
              const service = yield* Mcp.Service
              expect(yield* settled(service)).toEqual({ status: "connected" })
              // Initial listing succeeds.
              yield* service.prompts().pipe(
                Effect.filterOrFail((p) => p.length > 0, () => new Error("no prompts yet")),
                Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
              )
              console.log("before:", JSON.stringify((yield* service.prompts()).map((p) => p.name)))
              // Server announces a change, then fails the re-list.
              server.state.failPrompts = true
              yield* Effect.promise(server.sendPromptListChanged)
              yield* Effect.sleep("500 millis")
              const after = yield* service.prompts()
              console.log("after:", JSON.stringify(after.map((p) => p.name)))
              console.log("prompts/list calls:", server.state.promptLists)
              console.log("status:", JSON.stringify(yield* settled(service)))
              expect(after.map((p) => p.name)).toEqual(["greet"])
            }).pipe(Effect.provide(resourceMcpLayer(server.url)))
          }),
        ),
      )
    })
    '''
    open(p, "w").write(s)
    PY
    
    bun test "$TEST" -t "repro #50918"

    Run it: bash repro.sh

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions