From f4195bcd19350915c4cbb37726c5f8f87f370d74 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 1 Jun 2026 12:55:27 +0900 Subject: [PATCH 1/5] fix(app): hydrate MCP status during bootstrap --- .../src/context/global-sync/bootstrap.test.ts | 220 ++++++++++++------ .../app/src/context/global-sync/bootstrap.ts | 8 +- 2 files changed, 157 insertions(+), 71 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 8117e6139d96..2c2c1339c449 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,91 +1,173 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { Config, McpStatus, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { bootstrapDirectory } from "./bootstrap" import type { State, VcsCache } from "./types" const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const path = { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" } + +function createState(input?: { mcp?: Record; command?: State["command"] }) { + return createStore({ + status: "loading", + agent: [], + command: input?.command ?? [], + project: "", + projectMeta: undefined, + icon: undefined, + provider_ready: true, + provider, + config: {}, + path, + session: [], + sessionTotal: 0, + session_status: {}, + session_working(id: string) { + return this.session_status[id]?.type !== "idle" + }, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp_ready: true, + mcp: input?.mcp ?? {}, + lsp_ready: true, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, + part_text_accum_delta: {}, + }) +} + +function createSdk(input?: { mcp?: Record; command?: State["command"]; reads?: string[] }) { + return { + app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, + config: { get: async () => ({ data: {} }) }, + project: { current: async () => ({ data: { id: "project" } }) }, + session: { status: async () => ({ data: {} }) }, + vcs: { get: async () => ({ data: undefined }) }, + command: { + list: async () => { + input?.reads?.push("command") + return { data: input?.command ?? [] } + }, + }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + mcp: { + status: async () => { + input?.reads?.push("status") + return { data: input?.mcp ?? {} } + }, + }, + provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, + } as unknown as OpencodeClient +} + +async function bootstrap(input: { + store: State + setStore: ReturnType[1] + sdk: OpencodeClient + mcp: boolean +}) { + await bootstrapDirectory({ + directory: "/project", + mcp: input.mcp, + global: { + config: {} satisfies Config, + path, + project: [{ id: "project", worktree: "/project" } as Project], + provider, + }, + sdk: input.sdk, + store: input.store, + setStore: input.setStore, + vcsCache: { setStore() {} } as unknown as VcsCache, + loadSessions() {}, + translate: (key) => key, + queryClient: new QueryClient(), + }) +} + +async function waitForComplete(store: State) { + await new Promise((resolve) => setTimeout(resolve, 80)) + expect(store.status).toBe("complete") +} describe("bootstrapDirectory", () => { test("marks a loading directory partial during bootstrap and complete after success", async () => { const mcpReads: string[] = [] - const [store, setStore] = createStore({ - status: "loading", - agent: [], - command: [], - project: "", - projectMeta: undefined, - icon: undefined, - provider_ready: true, - provider, - config: {}, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - session: [], - sessionTotal: 0, - session_status: {}, - session_working(id: string) { - return this.session_status[id]?.type !== "idle" - }, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp_ready: true, - mcp: {}, - lsp_ready: true, - lsp: [], - vcs: undefined, - limit: 5, - message: {}, - part: {}, - part_text_accum_delta: {}, - }) + const [store, setStore] = createState() - await bootstrapDirectory({ - directory: "/project", - mcp: false, - global: { - config: {} satisfies Config, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - project: [{ id: "project", worktree: "/project" } as Project], - provider, - }, - sdk: { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { status: async () => ({ data: {} }) }, - vcs: { get: async () => ({ data: undefined }) }, - command: { - list: async () => { - mcpReads.push("command") - return { data: [] } - }, - }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - mcp: { - status: async () => { - mcpReads.push("status") - return { data: {} } - }, - }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient, + await bootstrap({ store, setStore, - vcsCache: { setStore() {} } as unknown as VcsCache, - loadSessions() {}, - translate: (key) => key, - queryClient: new QueryClient(), + mcp: false, + sdk: createSdk({ reads: mcpReads }), }) expect(store.status).toBe("partial") - await new Promise((resolve) => setTimeout(resolve, 80)) - - expect(store.status).toBe("complete") + await waitForComplete(store) expect(mcpReads).toEqual([]) }) + + test("hydrates connected MCP status during MCP bootstrap", async () => { + // given + const [store, setStore] = createState() + + // when + await bootstrap({ + store, + setStore, + mcp: true, + sdk: createSdk({ mcp: { qa: { status: "connected" } } }), + }) + await waitForComplete(store) + + // then + expect(store.mcp).toEqual({ qa: { status: "connected" } }) + }) + + test("clears stale MCP status when bootstrap returns no configured MCPs", async () => { + // given + const [store, setStore] = createState({ mcp: { stale: { status: "connected" } } }) + + // when + await bootstrap({ + store, + setStore, + mcp: true, + sdk: createSdk({ mcp: {} }), + }) + await waitForComplete(store) + + // then + expect(store.mcp).toEqual({}) + }) + + test("hydrates MCP status without dropping command results from the same bootstrap function", async () => { + // given + const [store, setStore] = createState() + + // when + await bootstrap({ + store, + setStore, + mcp: true, + sdk: createSdk({ + command: [{ name: "dev", template: "bun dev", hints: [] }], + mcp: { qa: { status: "connected" } }, + }), + }) + await waitForComplete(store) + + // then + expect(store.command).toEqual([{ name: "dev", template: "bun dev", hints: [] }]) + expect(store.mcp).toEqual({ qa: { status: "connected" } }) + }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ccbfc2aae924..6a271df2560f 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -305,7 +305,11 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))), + input.mcp && + (() => + input.queryClient + .fetchQuery(loadMcpQuery(input.directory, input.sdk)) + .then((data) => input.setStore("mcp", reconcile(data, { merge: false })))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { const project = getFilename(input.directory) @@ -315,7 +319,7 @@ export async function bootstrapDirectory(input: { description: formatServerError(err, input.translate), }) }), - ].filter(Boolean) as (() => Promise)[] + ].filter(Boolean) as Array<() => Promise> await waitForPaint() const slowErrs = errors(await runAll(slow)) From c0c2fd59f11360bdfbb9c4caa3de9f514c931e19 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 1 Jun 2026 12:55:53 +0900 Subject: [PATCH 2/5] fix(app): keep MCP state writable in child stores --- .../context/global-sync/child-store.test.ts | 92 ++++++++++++++++++- .../src/context/global-sync/child-store.ts | 17 ++-- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 7c4adb5216d9..59dc932a7c87 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test" import { createRoot, getOwner, type Owner } from "solid-js" import { createStore } from "solid-js/store" import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context" @@ -7,6 +7,7 @@ import type { QueryOptionsApi } from "../server-sync" let createChildStoreManager: typeof import("./child-store").createChildStoreManager const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = [] +let mcpQueryData: unknown const child = () => createStore({} as State) const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse @@ -31,6 +32,24 @@ const queryOptionsApi = { sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), } as unknown as QueryOptionsApi +class TestQueryClient { + fetchQuery(options: { queryFn?: () => unknown }) { + return Promise.resolve(options.queryFn?.()) + } + + ensureQueryData(options: { queryFn?: () => unknown }) { + return Promise.resolve(options.queryFn?.()) + } + + invalidateQueries() { + return Promise.resolve() + } + + refetchQueries() { + return Promise.resolve() + } +} + function createOwner(callback: (owner: Owner) => void) { return createRoot((dispose) => { const owner = getOwner() @@ -49,11 +68,16 @@ beforeAll(async () => { persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true], })) mock.module("@tanstack/solid-query", () => ({ + QueryClient: TestQueryClient, + queryOptions: (options: unknown) => options, + useMutation: () => ({ isPending: false, mutate() {}, mutateAsync: async () => undefined }), + useQuery: () => ({ isPending: false, refetch: async () => undefined }), + useQueryClient: () => new TestQueryClient(), useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => { queryGroups.push(options) return [ { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, - { isLoading: false, data: {} }, + { isLoading: false, data: mcpQueryData }, { isLoading: false, data: [] }, { isLoading: false, data: provider }, ] @@ -63,6 +87,10 @@ beforeAll(async () => { createChildStoreManager = (await import("./child-store")).createChildStoreManager }) +afterAll(() => { + mock.restore() +}) + describe("createChildStoreManager", () => { test("does not evict the active directory during mark", () => { const owner = createRoot((dispose) => { @@ -168,4 +196,64 @@ describe("createChildStoreManager", () => { dispose() } }) + + test("loads MCP when requested while the child store is still loading", () => { + let manager: ReturnType | undefined + const mcpLoads: string[] = [] + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + isBooting: () => true, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp(directory) { + mcpLoads.push(directory) + }, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + + manager.child("/project", { bootstrap: false, mcp: true }) + + expect(mcpLoads).toEqual(["/project"]) + } finally { + dispose() + } + }) + + test("allows bootstrap to hydrate MCP status into the child store", () => { + let manager: ReturnType | undefined + + const dispose = createOwner((owner) => { + manager = createChildStoreManager({ + owner, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onMcp() {}, + onDispose() {}, + translate: (key) => key, + queryOptions: queryOptionsApi, + global: { provider }, + }) + }) + + try { + if (!manager) throw new Error("manager required") + const [store, setStore] = manager.child("/project", { bootstrap: false, mcp: true }) + + setStore("mcp", { qa: { status: "connected" } }) + + expect(store.mcp).toEqual({ qa: { status: "connected" } }) + } finally { + dispose() + } + }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 99da39ebb0e8..eb41f7c683a5 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,5 +1,5 @@ -import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import { createEffect, createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" import { @@ -225,9 +225,7 @@ export function createChildStoreManager(input: { get mcp_ready() { return !mcpQuery.isLoading }, - get mcp() { - return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) - }, + mcp: {}, get lsp_ready() { return !lspQuery.isLoading }, @@ -244,6 +242,13 @@ export function createChildStoreManager(input: { disposers.set(key, dispose) mcpToggles.set(key, setMcpEnabled) + createEffect(() => { + if (mcpQuery.isLoading) return + const data = mcpQuery.data + if (data === undefined) return + child[1]("mcp", reconcile(data, { merge: false })) + }) + const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return void init.then(() => { @@ -304,7 +309,7 @@ export function createChildStoreManager(input: { if (mcpDirectories.has(key)) return mcpDirectories.add(key) mcpToggles.get(key)?.(true) - if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) + input.onMcp(directory, childStore[1]) } function disableMcp(directory: string) { From fe4b40aabd085dcb91969744c0ea4a90beb4d521 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 1 Jun 2026 12:56:03 +0900 Subject: [PATCH 3/5] fix(app): refresh MCP status on enable --- packages/app/src/context/server-sync.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index ad7e700cd7e3..5eab250a2baf 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -218,6 +218,16 @@ export function createServerSyncContext() { description: formatServerError(err, language.t), }) }) + void queryClient + .fetchQuery(queryOptionsApi.mcp(directoryKey(directory))) + .then((data) => setStore("mcp", reconcile(data, { merge: false }))) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) + }) }, onDispose: (directory) => { const key = directoryKey(directory) From 54b4e120aa1d8d39b6c6142c5fec733ee4fb48fd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 1 Jun 2026 12:58:36 +0900 Subject: [PATCH 4/5] fix(opencode): avoid global bus emit override --- packages/opencode/src/bus/global.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 3cfd453624c1..9124ab631a20 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -8,15 +8,21 @@ export type GlobalEvent = { payload: any } -class GlobalBusEmitter extends EventEmitter<{ +const bus = new EventEmitter<{ event: [GlobalEvent] -}> { - override emit(eventName: "event", event: GlobalEvent): boolean { +}>() + +export const GlobalBus = { + emit(eventName: "event", event: GlobalEvent): boolean { if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") } - return super.emit(eventName, event) - } + return bus.emit(eventName, event) + }, + on(eventName: "event", listener: (event: GlobalEvent) => void) { + return bus.on(eventName, listener) + }, + off(eventName: "event", listener: (event: GlobalEvent) => void) { + return bus.off(eventName, listener) + }, } - -export const GlobalBus = new GlobalBusEmitter() From a1389dcae5fcad1b981fbb87310106198fcba3fd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 1 Jun 2026 13:14:46 +0900 Subject: [PATCH 5/5] test(opencode): cover global bus wrapper --- packages/opencode/test/bus/global.test.ts | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/opencode/test/bus/global.test.ts diff --git a/packages/opencode/test/bus/global.test.ts b/packages/opencode/test/bus/global.test.ts new file mode 100644 index 000000000000..3c7930dda196 --- /dev/null +++ b/packages/opencode/test/bus/global.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { GlobalBus, type GlobalEvent } from "@/bus/global" + +describe("GlobalBus", () => { + test("adds an event id and delivers the event to listeners", () => { + // given + const received: GlobalEvent[] = [] + const listener = (event: GlobalEvent) => { + received.push(event) + } + GlobalBus.on("event", listener) + + try { + const event: GlobalEvent = { + payload: { + type: "server.connected", + properties: {}, + }, + } + + // when + const emitted = GlobalBus.emit("event", event) + + // then + expect(emitted).toBe(true) + expect(received).toEqual([event]) + expect(typeof event.payload.id).toBe("string") + } finally { + GlobalBus.off("event", listener) + } + }) + + test("removes listeners with off", () => { + // given + const received: GlobalEvent[] = [] + const listener = (event: GlobalEvent) => { + received.push(event) + } + GlobalBus.on("event", listener) + GlobalBus.off("event", listener) + + // when + const emitted = GlobalBus.emit("event", { + payload: { + id: "evt_existing", + type: "server.connected", + properties: {}, + }, + }) + + // then + expect(emitted).toBe(false) + expect(received).toEqual([]) + }) + + test("preserves an existing sync event id", () => { + // given + const event: GlobalEvent = { + payload: { + syncEvent: { id: "sync_123" }, + type: "session.updated", + properties: {}, + }, + } + + // when + GlobalBus.emit("event", event) + + // then + expect(event.payload.id).toBe("sync_123") + }) +})