Skip to content

Commit 570526c

Browse files
Apply PR #28937: fix(app): start MCP servers only for open directories
2 parents ed09552 + d73995a commit 570526c

9 files changed

Lines changed: 180 additions & 23 deletions

File tree

packages/app/src/context/directory-sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
178178
type Child = ReturnType<(typeof serverSync)["child"]>
179179
type Setter = Child[1]
180180

181-
const current = createMemo(() => serverSync.child(directory))
181+
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
182182
const target = (directory?: string) => {
183183
if (!directory || directory === directory) return current()
184184
return serverSync.child(directory)

packages/app/src/context/global-sync/bootstrap.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
1010

1111
describe("bootstrapDirectory", () => {
1212
test("marks a loading directory partial during bootstrap and complete after success", async () => {
13+
const mcpReads: string[] = []
1314
const [store, setStore] = createStore<State>({
1415
status: "loading",
1516
agent: [],
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
4445

4546
await bootstrapDirectory({
4647
directory: "/project",
48+
mcp: false,
4749
global: {
4850
config: {} satisfies Config,
4951
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
5557
config: { get: async () => ({ data: {} }) },
5658
session: { status: async () => ({ data: {} }) },
5759
vcs: { get: async () => ({ data: undefined }) },
58-
command: { list: async () => ({ data: [] }) },
60+
command: {
61+
list: async () => {
62+
mcpReads.push("command")
63+
return { data: [] }
64+
},
65+
},
5966
permission: { list: async () => ({ data: [] }) },
6067
question: { list: async () => ({ data: [] }) },
61-
mcp: { status: async () => ({ data: {} }) },
68+
mcp: {
69+
status: async () => {
70+
mcpReads.push("status")
71+
return { data: {} }
72+
},
73+
},
6274
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
6375
} as unknown as OpencodeClient,
6476
store,
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
7486
await new Promise((resolve) => setTimeout(resolve, 80))
7587

7688
expect(store.status).toBe("complete")
89+
expect(mcpReads).toEqual([])
7790
})
7891
})

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
198198

199199
export async function bootstrapDirectory(input: {
200200
directory: string
201+
mcp: boolean
201202
sdk: OpencodeClient
202203
store: Store<State>
203204
setStore: SetStoreFunction<State>
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
250251
if (next) input.vcsCache.setStore("value", next)
251252
}),
252253
),
253-
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
254+
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
254255
() =>
255256
retry(() =>
256257
input.sdk.permission.list().then((x) => {
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
304305
}),
305306
),
306307
() => Promise.resolve(input.loadSessions(input.directory)),
307-
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
308+
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
308309
() =>
309310
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
310311
const project = getFilename(input.directory)

packages/app/src/context/global-sync/child-store.test.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { State } from "./types"
66
import type { QueryOptionsApi } from "../server-sync"
77

88
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
9+
const queryGroups: Array<() => { queries: Array<{ enabled?: () => boolean }> }> = []
910

1011
const child = () => createStore({} as State)
1112
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -48,12 +49,15 @@ beforeAll(async () => {
4849
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
4950
}))
5051
mock.module("@tanstack/solid-query", () => ({
51-
useQueries: () => [
52-
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
53-
{ isLoading: false, data: {} },
54-
{ isLoading: false, data: [] },
55-
{ isLoading: false, data: provider },
56-
],
52+
useQueries: (options: () => { queries: Array<{ enabled?: () => boolean }> }) => {
53+
queryGroups.push(options)
54+
return [
55+
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
56+
{ isLoading: false, data: {} },
57+
{ isLoading: false, data: [] },
58+
{ isLoading: false, data: provider },
59+
]
60+
},
5761
}))
5862

5963
createChildStoreManager = (await import("./child-store")).createChildStoreManager
@@ -73,6 +77,7 @@ describe("createChildStoreManager", () => {
7377
isBooting: () => false,
7478
isLoadingSessions: () => false,
7579
onBootstrap() {},
80+
onMcp() {},
7681
onDispose() {},
7782
translate: (key) => key,
7883
queryOptions: queryOptionsApi,
@@ -103,6 +108,7 @@ describe("createChildStoreManager", () => {
103108
onBootstrap(directory) {
104109
bootstraps.push(directory)
105110
},
111+
onMcp() {},
106112
onDispose() {},
107113
translate: (key) => key,
108114
queryOptions: queryOptionsApi,
@@ -121,4 +127,45 @@ describe("createChildStoreManager", () => {
121127
dispose()
122128
}
123129
})
130+
131+
test("enables MCP only when requested for the directory", () => {
132+
let manager: ReturnType<typeof createChildStoreManager> | undefined
133+
const offset = queryGroups.length
134+
const mcpLoads: string[] = []
135+
136+
const dispose = createOwner((owner) => {
137+
manager = createChildStoreManager({
138+
owner,
139+
isBooting: () => false,
140+
isLoadingSessions: () => false,
141+
onBootstrap() {},
142+
onMcp(directory) {
143+
mcpLoads.push(directory)
144+
},
145+
onDispose() {},
146+
translate: (key) => key,
147+
queryOptions: queryOptionsApi,
148+
global: { provider },
149+
})
150+
})
151+
152+
try {
153+
if (!manager) throw new Error("manager required")
154+
const [, setStore] = manager.child("/project", { bootstrap: false })
155+
const queries = queryGroups[offset]
156+
if (!queries) throw new Error("queries required")
157+
expect(queries().queries[1]?.enabled?.()).toBe(false)
158+
159+
setStore("status", "complete")
160+
manager.child("/project", { bootstrap: false, mcp: true })
161+
expect(queries().queries[1]?.enabled?.()).toBe(true)
162+
expect(mcpLoads).toEqual(["/project"])
163+
164+
manager.disableMcp("/project")
165+
expect(queries().queries[1]?.enabled?.()).toBe(false)
166+
expect(manager.mcp("/project")).toBe(false)
167+
} finally {
168+
dispose()
169+
}
170+
})
124171
})

packages/app/src/context/global-sync/child-store.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
1+
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
22
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
33
import { Persist, persisted } from "@/utils/persist"
44
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
2424
isBooting: (directory: string) => boolean
2525
isLoadingSessions: (directory: string) => boolean
2626
onBootstrap: (directory: string) => void
27+
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
2728
onDispose: (directory: string) => void
2829
translate: (key: string, vars?: Record<string, string | number>) => string
2930
queryOptions: QueryOptionsApi
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
3940
const pins = new Map<string, number>()
4041
const ownerPins = new WeakMap<object, Set<string>>()
4142
const disposers = new Map<string, () => void>()
43+
const mcpDirectories = new Set<string>()
44+
const mcpToggles = new Map<string, (enabled: boolean) => void>()
4245

4346
const markKey = (key: DirectoryKey) => {
4447
if (!key) return
@@ -99,6 +102,8 @@ export function createChildStoreManager(input: {
99102
metaCache.delete(key)
100103
iconCache.delete(key)
101104
lifecycle.delete(key)
105+
mcpDirectories.delete(key)
106+
mcpToggles.delete(key)
102107
disposers.delete(key)
103108
delete children[key]
104109
input.onDispose(key)
@@ -184,11 +189,12 @@ export function createChildStoreManager(input: {
184189
createRoot((dispose) => {
185190
const initialMeta = meta[0].value
186191
const initialIcon = icon[0].value
192+
const [mcpEnabled, setMcpEnabled] = createSignal(false)
187193

188194
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
189195
queries: [
190196
input.queryOptions.path(key),
191-
input.queryOptions.mcp(key),
197+
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled },
192198
input.queryOptions.lsp(key),
193199
input.queryOptions.providers(key),
194200
],
@@ -247,6 +253,7 @@ export function createChildStoreManager(input: {
247253
})
248254
children[key] = child
249255
disposers.set(key, dispose)
256+
mcpToggles.set(key, setMcpEnabled)
250257

251258
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
252259
if (!(init instanceof Promise)) return
@@ -285,6 +292,7 @@ export function createChildStoreManager(input: {
285292
const key = directoryKey(directory)
286293
const childStore = ensureChild(directory)
287294
pinForOwner(key)
295+
if (options.mcp) enableMcp(directory, key, childStore)
288296
const shouldBootstrap = options.bootstrap ?? true
289297
if (shouldBootstrap && childStore[0].status === "loading") {
290298
input.onBootstrap(directory)
@@ -295,13 +303,27 @@ export function createChildStoreManager(input: {
295303
function peek(directory: string, options: ChildOptions = {}) {
296304
const key = directoryKey(directory)
297305
const childStore = ensureChild(directory)
306+
if (options.mcp) enableMcp(directory, key, childStore)
298307
const shouldBootstrap = options.bootstrap ?? true
299308
if (shouldBootstrap && childStore[0].status === "loading") {
300309
input.onBootstrap(directory)
301310
}
302311
return childStore
303312
}
304313

314+
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
315+
if (mcpDirectories.has(key)) return
316+
mcpDirectories.add(key)
317+
mcpToggles.get(key)?.(true)
318+
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
319+
}
320+
321+
function disableMcp(directory: string) {
322+
const key = directoryKey(directory)
323+
if (!mcpDirectories.delete(key)) return
324+
mcpToggles.get(key)?.(false)
325+
}
326+
305327
function projectMeta(directory: string, patch: ProjectMeta) {
306328
const key = directoryKey(directory)
307329
const [store, setStore] = ensureChild(directory)
@@ -341,6 +363,8 @@ export function createChildStoreManager(input: {
341363
pin,
342364
unpin,
343365
pinned,
366+
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
367+
disableMcp,
344368
disposeDirectory,
345369
disposeAll,
346370
runEviction,

packages/app/src/context/global-sync/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export type IconCache = {
9898

9999
export type ChildOptions = {
100100
bootstrap?: boolean
101+
mcp?: boolean
101102
}
102103

103104
export type DirState = {

packages/app/src/context/server-sync.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
3131
import { createDirSyncContext } from "./directory-sync"
3232
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
3333
import { createRefCountMap } from "@/utils/refcount"
34+
import { retry } from "@opencode-ai/core/util/retry"
3435

3536
type GlobalStore = {
3637
ready: boolean
@@ -205,6 +206,15 @@ export function createServerSyncContext() {
205206
onBootstrap: (directory) => {
206207
void bootstrapInstance(directory)
207208
},
209+
onMcp: (directory, setStore) => {
210+
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
211+
showToast({
212+
variant: "error",
213+
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
214+
description: formatServerError(err, language.t),
215+
})
216+
})
217+
},
208218
onDispose: (directory) => {
209219
const key = directoryKey(directory)
210220
queue.clear(key)
@@ -311,6 +321,7 @@ export function createServerSyncContext() {
311321
const sdk = sdkFor(directory)
312322
await bootstrapDirectory({
313323
directory,
324+
mcp: children.mcp(key),
314325
global: {
315326
config: globalStore.config,
316327
path: globalStore.path,
@@ -433,6 +444,7 @@ export function createServerSyncContext() {
433444
},
434445
child: children.child,
435446
peek: children.peek,
447+
disableMcp: children.disableMcp,
436448
queryOptions: queryOptionsApi,
437449
// bootstrap,
438450
updateConfig: updateConfigMutation.mutateAsync,
@@ -450,7 +462,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
450462

451463
return {
452464
...sync,
453-
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
465+
createDirSyncContext: createRefCountMap(
466+
(dir) => createDirSyncContext(dir, sync),
467+
(dir) => sync.disableMcp(dir),
468+
directoryKey,
469+
),
454470
}
455471
},
456472
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createRoot } from "solid-js"
3+
import { createRefCountMap } from "./refcount"
4+
import { pathKey } from "./path-key"
5+
6+
describe("createRefCountMap", () => {
7+
test("removes an item after its last owner is disposed", () => {
8+
const removed: string[] = []
9+
const map = createRefCountMap(
10+
(key) => key,
11+
(key) => removed.push(key),
12+
)
13+
const first = createRoot((dispose) => {
14+
map("/project")
15+
return dispose
16+
})
17+
const second = createRoot((dispose) => {
18+
map("/project")
19+
return dispose
20+
})
21+
22+
first()
23+
expect(removed).toEqual([])
24+
second()
25+
expect(removed).toEqual(["/project"])
26+
})
27+
28+
test("keeps equivalent path consumers until the last owner is disposed", () => {
29+
const removed: string[] = []
30+
const map = createRefCountMap(
31+
(key) => key,
32+
(key) => removed.push(key),
33+
pathKey,
34+
)
35+
const first = createRoot((dispose) => {
36+
map("C:\\repo")
37+
return dispose
38+
})
39+
const second = createRoot((dispose) => {
40+
map("C:/repo/")
41+
return dispose
42+
})
43+
44+
first()
45+
expect(removed).toEqual([])
46+
second()
47+
expect(removed).toEqual(["C:/repo"])
48+
})
49+
})

0 commit comments

Comments
 (0)