fix(app): start MCP servers only for open directories (#28937)

This commit is contained in:
Luke Parker
2026-05-29 07:51:11 +10:00
committed by GitHub
parent 7b56a1cea3
commit e16bfd745d
9 changed files with 180 additions and 23 deletions

View File

@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
type Child = ReturnType<(typeof serverSync)["child"]> type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1] type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory)) const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const target = (directory?: string) => { const target = (directory?: string) => {
if (!directory || directory === directory) return current() if (!directory || directory === directory) return current()
return serverSync.child(directory) return serverSync.child(directory)

View File

@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
describe("bootstrapDirectory", () => { describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => { test("marks a loading directory partial during bootstrap and complete after success", async () => {
const mcpReads: string[] = []
const [store, setStore] = createStore<State>({ const [store, setStore] = createStore<State>({
status: "loading", status: "loading",
agent: [], agent: [],
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({ await bootstrapDirectory({
directory: "/project", directory: "/project",
mcp: false,
global: { global: {
config: {} satisfies Config, config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
config: { get: async () => ({ data: {} }) }, config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) }, session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) }, vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) }, command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) }, permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) }, mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient, } as unknown as OpencodeClient,
store, store,
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80)) await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete") expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
}) })
}) })

View File

@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
mcp: boolean
sdk: OpencodeClient sdk: OpencodeClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
if (next) input.vcsCache.setStore("value", next) if (next) input.vcsCache.setStore("value", next)
}), }),
), ),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => () =>
retry(() => retry(() =>
input.sdk.permission.list().then((x) => { input.sdk.permission.list().then((x) => {
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory) const project = getFilename(input.directory)

View File

@@ -6,6 +6,7 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync" import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
const child = () => createStore({} as State) const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -48,12 +49,15 @@ beforeAll(async () => {
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true], persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
})) }))
mock.module("@tanstack/solid-query", () => ({ mock.module("@tanstack/solid-query", () => ({
useQueries: () => [ useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } }, queryGroups.push(options)
{ isLoading: false, data: {} }, return [
{ isLoading: false, data: [] }, { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: provider }, { isLoading: false, data: {} },
], { isLoading: false, data: [] },
{ isLoading: false, data: provider },
]
},
})) }))
createChildStoreManager = (await import("./child-store")).createChildStoreManager createChildStoreManager = (await import("./child-store")).createChildStoreManager
@@ -73,6 +77,7 @@ describe("createChildStoreManager", () => {
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
onMcp() {},
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -103,6 +108,7 @@ describe("createChildStoreManager", () => {
onBootstrap(directory) { onBootstrap(directory) {
bootstraps.push(directory) bootstraps.push(directory)
}, },
onMcp() {},
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
@@ -121,4 +127,45 @@ describe("createChildStoreManager", () => {
dispose() dispose()
} }
}) })
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = queryGroups.length
const mcpLoads: string[] = []
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onMcp(directory) {
mcpLoads.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [, setStore] = manager.child("/project", { bootstrap: false })
const queries = queryGroups[offset]
if (!queries) throw new Error("queries required")
expect(queries().queries[1]?.enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(queries().queries[1]?.enabled).toBe(true)
expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project")
expect(queries().queries[1]?.enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false)
} finally {
dispose()
}
})
}) })

View File

@@ -1,4 +1,4 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client" import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi queryOptions: QueryOptionsApi
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
const pins = new Map<string, number>() const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>() const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>() const disposers = new Map<string, () => void>()
const mcpDirectories = new Set<string>()
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => { const markKey = (key: DirectoryKey) => {
if (!key) return if (!key) return
@@ -110,6 +113,8 @@ export function createChildStoreManager(input: {
metaCache.delete(key) metaCache.delete(key)
iconCache.delete(key) iconCache.delete(key)
lifecycle.delete(key) lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
const dispose = disposers.get(key) const dispose = disposers.get(key)
if (dispose) { if (dispose) {
dispose() dispose()
@@ -173,11 +178,12 @@ export function createChildStoreManager(input: {
createRoot((dispose) => { createRoot((dispose) => {
const initialMeta = meta[0].value const initialMeta = meta[0].value
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [ queries: [
input.queryOptions.path(key), input.queryOptions.path(key),
input.queryOptions.mcp(key), { ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
input.queryOptions.lsp(key), input.queryOptions.lsp(key),
input.queryOptions.providers(key), input.queryOptions.providers(key),
], ],
@@ -236,6 +242,7 @@ export function createChildStoreManager(input: {
}) })
children[key] = child children[key] = child
disposers.set(key, dispose) disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
@@ -274,6 +281,7 @@ export function createChildStoreManager(input: {
const key = directoryKey(directory) const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
pinForOwner(key) pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
@@ -284,6 +292,7 @@ export function createChildStoreManager(input: {
function peek(directory: string, options: ChildOptions = {}) { function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory) const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
@@ -291,6 +300,19 @@ export function createChildStoreManager(input: {
return childStore return childStore
} }
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
if (mcpDirectories.has(key)) return
mcpDirectories.add(key)
mcpToggles.get(key)?.(true)
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
}
function disableMcp(directory: string) {
const key = directoryKey(directory)
if (!mcpDirectories.delete(key)) return
mcpToggles.get(key)?.(false)
}
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory) const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
@@ -330,6 +352,8 @@ export function createChildStoreManager(input: {
pin, pin,
unpin, unpin,
pinned, pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
disableMcp,
disposeDirectory, disposeDirectory,
runEviction, runEviction,
vcsCache, vcsCache,

View File

@@ -98,6 +98,7 @@ export type IconCache = {
export type ChildOptions = { export type ChildOptions = {
bootstrap?: boolean bootstrap?: boolean
mcp?: boolean
} }
export type DirState = { export type DirState = {

View File

@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { retry } from "@opencode-ai/core/util/retry"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -205,6 +206,15 @@ export function createServerSyncContext() {
onBootstrap: (directory) => { onBootstrap: (directory) => {
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onMcp: (directory, setStore) => {
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
showToast({
variant: "error",
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
})
})
},
onDispose: (directory) => { onDispose: (directory) => {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
@@ -311,6 +321,7 @@ export function createServerSyncContext() {
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
mcp: children.mcp(key),
global: { global: {
config: globalStore.config, config: globalStore.config,
path: globalStore.path, path: globalStore.path,
@@ -437,6 +448,7 @@ export function createServerSyncContext() {
}, },
child: children.child, child: children.child,
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
@@ -454,7 +466,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
return { return {
...sync, ...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)), createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, sync),
(dir) => sync.disableMcp(dir),
directoryKey,
),
} }
}, },
}) })

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createRefCountMap } from "./refcount"
import { pathKey } from "./path-key"
describe("createRefCountMap", () => {
test("removes an item after its last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
)
const first = createRoot((dispose) => {
map("/project")
return dispose
})
const second = createRoot((dispose) => {
map("/project")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["/project"])
})
test("keeps equivalent path consumers until the last owner is disposed", () => {
const removed: string[] = []
const map = createRefCountMap(
(key) => key,
(key) => removed.push(key),
pathKey,
)
const first = createRoot((dispose) => {
map("C:\\repo")
return dispose
})
const second = createRoot((dispose) => {
map("C:/repo/")
return dispose
})
first()
expect(removed).toEqual([])
second()
expect(removed).toEqual(["C:/repo"])
})
})

View File

@@ -1,26 +1,32 @@
import { onCleanup } from "solid-js" import { onCleanup } from "solid-js"
export function createRefCountMap<T>(create: (key: string) => T) { export function createRefCountMap<T>(
create: (key: string) => T,
remove?: (key: string) => void,
identity: (key: string) => string = (key) => key,
) {
const items = new Map<string, T>() const items = new Map<string, T>()
const refCounts = new Map<string, number>() const refCounts = new Map<string, number>()
return (key: string) => { return (key: string) => {
const id = identity(key)
onCleanup(() => { onCleanup(() => {
refCounts.set(key, (refCounts.get(key) ?? 0) - 1) refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
if (refCounts.get(key) === 0) { if (refCounts.get(id) === 0) {
items.delete(key) remove?.(id)
refCounts.delete(key) items.delete(id)
refCounts.delete(id)
} }
}) })
const cached = items.get(key) const cached = items.get(id)
if (cached) { if (cached) {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1) refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
return cached return cached
} }
const item = create(key) const item = create(key)
items.set(key, item) items.set(id, item)
refCounts.set(key, 1) refCounts.set(id, 1)
return item return item
} }
} }