mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-23 12:54:42 +00:00
refactor(app): split shared changes from desktop v2
This commit is contained in:
parent
0302c54713
commit
0b4eb2a2ff
14 changed files with 136 additions and 365 deletions
|
|
@ -11,7 +11,6 @@ import { pathKey } from "@/utils/path-key"
|
|||
|
||||
const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
connecting: "mcp.status.connecting",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
|
|
@ -80,7 +79,6 @@ export const DialogSelectMcp: Component = () => {
|
|||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
const connecting = () => status() === "connecting"
|
||||
return (
|
||||
<div class="w-full flex items-center justify-between gap-x-3">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
|
|
@ -97,9 +95,8 @@ export const DialogSelectMcp: Component = () => {
|
|||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={connecting() || (toggle.isPending && toggle.variables === i.name)}
|
||||
disabled={toggle.isPending && toggle.variables === i.name}
|
||||
onChange={() => {
|
||||
if (connecting()) return
|
||||
if (toggle.isPending) return
|
||||
toggle.mutate(i.name)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ import { pathKey } from "@/utils/path-key"
|
|||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
variant?: "dock" | "new-session"
|
||||
|
|
@ -1129,9 +1131,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => setStore("popover", popover),
|
||||
newSessionProjectDirectory,
|
||||
newSessionWorktree,
|
||||
newSessionWorktreeBranch: () => picker.worktreeName,
|
||||
newSessionProjectDirectory: USE_V2_INPUT ? newSessionProjectDirectory : undefined,
|
||||
newSessionWorktree: USE_V2_INPUT ? newSessionWorktree : () => props.newSessionWorktree,
|
||||
newSessionWorktreeBranch: USE_V2_INPUT ? () => picker.worktreeName : undefined,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
|
|
@ -1588,8 +1590,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
onPress: () => command.trigger("project.open"),
|
||||
}))
|
||||
|
||||
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
return (
|
||||
<div class="relative size-full flex flex-col gap-0">
|
||||
{(promptReady(), null)}
|
||||
|
|
|
|||
|
|
@ -451,17 +451,15 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
const connecting = () => status() === "connecting"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (connecting()) return
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={connecting() || (toggleMcp.isPending && toggleMcp.variables === name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -469,7 +467,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base animate-pulse": status() === "connecting",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
|
|
@ -487,9 +484,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={connecting() || (toggleMcp.isPending && toggleMcp.variables === name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (connecting()) return
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
|
|
@ -59,8 +49,6 @@ export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
|
|||
queryOptions({
|
||||
queryKey: [directory, "mcp"] as const,
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
refetchInterval: (query) =>
|
||||
Object.values(query.state.data ?? {}).some((status) => status.status === "connecting") ? 1000 : false,
|
||||
})
|
||||
|
||||
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
|
||||
|
|
@ -144,7 +132,6 @@ function createGlobalSync() {
|
|||
let bootingRoot = false
|
||||
let eventFrame: number | undefined
|
||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [childVersion, setChildVersion] = createSignal(0)
|
||||
|
||||
onCleanup(() => {
|
||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||
|
|
@ -217,7 +204,6 @@ function createGlobalSync() {
|
|||
onBootstrap: (directory) => {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onCreate: () => setChildVersion((value) => value + 1),
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
|
|
@ -452,8 +438,6 @@ function createGlobalSync() {
|
|||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
childVersion,
|
||||
existing: children.existing,
|
||||
peek: children.peek,
|
||||
queryOptions: queryOptionsApi,
|
||||
// bootstrap,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ describe("createChildStoreManager", () => {
|
|||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onCreate() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
|
|
@ -104,7 +103,6 @@ describe("createChildStoreManager", () => {
|
|||
onBootstrap(directory) {
|
||||
bootstraps.push(directory)
|
||||
},
|
||||
onCreate() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ export function createChildStoreManager(input: {
|
|||
isBooting: (directory: string) => boolean
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
onCreate: () => void
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
|
|
@ -236,7 +235,6 @@ export function createChildStoreManager(input: {
|
|||
part_text_accum_delta: {},
|
||||
})
|
||||
children[key] = child
|
||||
input.onCreate()
|
||||
disposers.set(key, dispose)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
|
|
@ -283,10 +281,6 @@ export function createChildStoreManager(input: {
|
|||
return childStore
|
||||
}
|
||||
|
||||
function existing(directory: string) {
|
||||
return children[directoryKey(directory)]
|
||||
}
|
||||
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const key = directoryKey(directory)
|
||||
const childStore = ensureChild(directory)
|
||||
|
|
@ -328,7 +322,6 @@ export function createChildStoreManager(input: {
|
|||
return {
|
||||
children,
|
||||
ensureChild,
|
||||
existing,
|
||||
child,
|
||||
peek,
|
||||
projectMeta,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
|||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type {
|
||||
Message,
|
||||
McpStatus,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
|
|
@ -183,11 +182,6 @@ export function applyDirectoryEvent(input: {
|
|||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
break
|
||||
}
|
||||
case "mcp.status.changed": {
|
||||
const props = event.properties as { name: string; status: McpStatus }
|
||||
input.setStore("mcp", props.name, reconcile(props.status))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = clean((event.properties as { info: Message }).info)
|
||||
const messages = input.store.message[info.sessionID]
|
||||
|
|
|
|||
|
|
@ -386,16 +386,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const childStore = globalSync.existing(project.worktree)?.[0]
|
||||
const projectID = childStore?.project
|
||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
||||
const projectID = childStore.project
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
|
||||
// Use child metadata only after the workspace is already loaded. Creating child
|
||||
// stores here fans out workspace bootstrap requests while rendering the project list.
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
// icon from the database instead of using their individual overrides.
|
||||
const base = { ...metadata, ...project }
|
||||
if (childStore?.icon) {
|
||||
if (childStore.icon) {
|
||||
return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
||||
}
|
||||
return base
|
||||
|
|
@ -474,7 +475,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
if (!globalSync.existing(project.worktree)) continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
|
@ -521,6 +521,28 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
}
|
||||
})
|
||||
|
||||
let sessionFrame: number | undefined
|
||||
let sessionTimer: number | undefined
|
||||
|
||||
onMount(() => {
|
||||
sessionFrame = requestAnimationFrame(() => {
|
||||
sessionFrame = undefined
|
||||
sessionTimer = window.setTimeout(() => {
|
||||
sessionTimer = undefined
|
||||
void Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
}, 0)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
|
||||
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
handoff: {
|
||||
|
|
|
|||
|
|
@ -304,7 +304,6 @@ export const dict = {
|
|||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||
|
||||
"mcp.status.connected": "connected",
|
||||
"mcp.status.connecting": "connecting",
|
||||
"mcp.status.failed": "failed",
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
|
|
|
|||
|
|
@ -79,9 +79,7 @@ function HomeDesign() {
|
|||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
const [root] = projectDirectories()
|
||||
if (!root) return null
|
||||
await sync.project.loadSessions(root)
|
||||
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
|
@ -89,16 +87,11 @@ function HomeDesign() {
|
|||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() => {
|
||||
sync.childVersion()
|
||||
return [
|
||||
const records = createMemo(() =>
|
||||
[
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => {
|
||||
const store = sync.existing(directory)?.[0]
|
||||
if (!store) return []
|
||||
return sortedRootSessions(store, Date.now())
|
||||
})
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
|
||||
).values(),
|
||||
]
|
||||
|
|
@ -117,8 +110,8 @@ function HomeDesign() {
|
|||
if (!value) return true
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT),
|
||||
)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
|
||||
function selectProject(directory: string) {
|
||||
|
|
|
|||
|
|
@ -143,9 +143,6 @@ export const McpListCommand = effectCmd({
|
|||
} else if (status.status === "disabled") {
|
||||
statusIcon = "○"
|
||||
statusText = "disabled"
|
||||
} else if (status.status === "connecting") {
|
||||
statusIcon = "…"
|
||||
statusText = "connecting"
|
||||
} else if (status.status === "needs_auth") {
|
||||
statusIcon = "⚠"
|
||||
statusText = "needs authentication"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { BusEvent } from "../bus/bus-event"
|
|||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Schema, Stream, Scope, Semaphore } from "effect"
|
||||
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
|
|
@ -79,9 +79,6 @@ const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).a
|
|||
const StatusDisabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
|
||||
identifier: "MCPStatusDisabled",
|
||||
})
|
||||
const StatusConnecting = Schema.Struct({ status: Schema.Literal("connecting") }).annotate({
|
||||
identifier: "MCPStatusConnecting",
|
||||
})
|
||||
const StatusFailed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({
|
||||
identifier: "MCPStatusFailed",
|
||||
})
|
||||
|
|
@ -96,21 +93,12 @@ const StatusNeedsClientRegistration = Schema.Struct({
|
|||
export const Status = Schema.Union([
|
||||
StatusConnected,
|
||||
StatusDisabled,
|
||||
StatusConnecting,
|
||||
StatusFailed,
|
||||
StatusNeedsAuth,
|
||||
StatusNeedsClientRegistration,
|
||||
]).annotate({ identifier: "MCPStatus", discriminator: "status" })
|
||||
export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
export const StatusChanged = BusEvent.define(
|
||||
"mcp.status.changed",
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
status: Status,
|
||||
}),
|
||||
)
|
||||
|
||||
// Store transports for OAuth servers to allow finishing auth
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
|
|
@ -249,7 +237,6 @@ interface State {
|
|||
status: Record<string, Status>
|
||||
clients: Record<string, MCPClient>
|
||||
defs: Record<string, MCPToolDef[]>
|
||||
revision: Record<string, number>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -493,7 +480,6 @@ export const layer = Layer.effect(
|
|||
return { mcpClient, status, defs: listed } satisfies CreateResult
|
||||
})
|
||||
const cfgSvc = yield* Config.Service
|
||||
const startupLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const descendants = Effect.fnUntraced(
|
||||
function* (pid: number) {
|
||||
|
|
@ -533,133 +519,43 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}
|
||||
|
||||
function failedStatus(error: unknown): Status {
|
||||
return { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
function bump(s: State, name: string) {
|
||||
const next = (s.revision[name] ?? 0) + 1
|
||||
s.revision[name] = next
|
||||
return next
|
||||
}
|
||||
|
||||
function closeClient(s: State, name: string) {
|
||||
const client = s.clients[name]
|
||||
delete s.defs[name]
|
||||
if (!client) return Effect.void
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
function closeCreateResult(result: CreateResult) {
|
||||
const client = result.mcpClient
|
||||
if (!client) return Effect.void
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
const setStatus = Effect.fnUntraced(function* (s: State, name: string, status: Status) {
|
||||
s.status[name] = status
|
||||
yield* bus.publish(StatusChanged, { name, status }).pipe(Effect.ignore)
|
||||
return status
|
||||
})
|
||||
|
||||
const storeClient = Effect.fnUntraced(function* (
|
||||
s: State,
|
||||
name: string,
|
||||
client: MCPClient,
|
||||
listed: MCPToolDef[],
|
||||
timeout?: number,
|
||||
) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
yield* closeClient(s, name)
|
||||
s.clients[name] = client
|
||||
s.defs[name] = listed
|
||||
watch(s, name, client, bridge, timeout)
|
||||
return yield* setStatus(s, name, { status: "connected" })
|
||||
})
|
||||
|
||||
const applyCreateResult = Effect.fnUntraced(function* (
|
||||
s: State,
|
||||
name: string,
|
||||
result: CreateResult,
|
||||
timeout?: number,
|
||||
) {
|
||||
const client = result.mcpClient
|
||||
if (!client) {
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
return yield* setStatus(s, name, result.status)
|
||||
}
|
||||
|
||||
if (!result.defs) {
|
||||
yield* closeCreateResult(result)
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
return yield* setStatus(s, name, { status: "failed", error: "Failed to get tools" })
|
||||
}
|
||||
|
||||
return yield* storeClient(s, name, client, result.defs, timeout)
|
||||
})
|
||||
|
||||
const createSafely = (key: string, mcp: ConfigMCP.Info) =>
|
||||
create(key, mcp).pipe(
|
||||
Effect.catch((error) => {
|
||||
log.error("mcp startup failed", { key, error })
|
||||
return Effect.succeed({ status: failedStatus(error) } satisfies CreateResult)
|
||||
}),
|
||||
)
|
||||
|
||||
const startConfigured = Effect.fn("MCP.startConfigured")(function* (
|
||||
s: State,
|
||||
entries: ReadonlyArray<readonly [string, ConfigMCP.Info]>,
|
||||
) {
|
||||
yield* startupLock.withPermits(1)(
|
||||
Effect.forEach(
|
||||
entries,
|
||||
([key, mcp]) =>
|
||||
Effect.gen(function* () {
|
||||
const revision = s.revision[key] ?? 0
|
||||
const result = yield* createSafely(key, mcp)
|
||||
if ((s.revision[key] ?? 0) !== revision) {
|
||||
yield* closeCreateResult(result)
|
||||
return
|
||||
}
|
||||
yield* applyCreateResult(s, key, result, mcp.timeout)
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const scope = yield* Scope.Scope
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
status: {},
|
||||
clients: {},
|
||||
defs: {},
|
||||
revision: {},
|
||||
}
|
||||
|
||||
const configured = Object.entries(config).flatMap(([key, mcp]) => {
|
||||
if (!isMcpConfigured(mcp)) {
|
||||
log.error("Ignoring MCP config entry without type", { key })
|
||||
return []
|
||||
}
|
||||
yield* Effect.forEach(
|
||||
Object.entries(config),
|
||||
([key, mcp]) =>
|
||||
Effect.gen(function* () {
|
||||
if (!isMcpConfigured(mcp)) {
|
||||
log.error("Ignoring MCP config entry without type", { key })
|
||||
return
|
||||
}
|
||||
|
||||
if (mcp.enabled === false) {
|
||||
s.status[key] = { status: "disabled" }
|
||||
return []
|
||||
}
|
||||
if (mcp.enabled === false) {
|
||||
s.status[key] = { status: "disabled" }
|
||||
return
|
||||
}
|
||||
|
||||
s.status[key] = { status: "connecting" }
|
||||
return [[key, mcp] as const]
|
||||
})
|
||||
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
|
||||
if (!result) return
|
||||
|
||||
if (configured.length > 0) {
|
||||
yield* startConfigured(s, configured).pipe(Effect.ignore, Effect.forkIn(scope), Effect.asVoid)
|
||||
}
|
||||
s.status[key] = result.status
|
||||
if (result.mcpClient) {
|
||||
s.clients[key] = result.mcpClient
|
||||
s.defs[key] = result.defs!
|
||||
watch(s, key, result.mcpClient, bridge, mcp.timeout)
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -688,6 +584,29 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
function closeClient(s: State, name: string) {
|
||||
const client = s.clients[name]
|
||||
delete s.defs[name]
|
||||
if (!client) return Effect.void
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
const storeClient = Effect.fnUntraced(function* (
|
||||
s: State,
|
||||
name: string,
|
||||
client: MCPClient,
|
||||
listed: MCPToolDef[],
|
||||
timeout?: number,
|
||||
) {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
yield* closeClient(s, name)
|
||||
s.status[name] = { status: "connected" }
|
||||
s.clients[name] = client
|
||||
s.defs[name] = listed
|
||||
watch(s, name, client, bridge, timeout)
|
||||
return s.status[name]
|
||||
})
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
|
|
@ -710,15 +629,16 @@ export const layer = Layer.effect(
|
|||
|
||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const revision = bump(s, name)
|
||||
yield* setStatus(s, name, mcp.enabled === false ? { status: "disabled" } : { status: "connecting" })
|
||||
const result = yield* createSafely(name, mcp)
|
||||
if ((s.revision[name] ?? 0) !== revision) {
|
||||
yield* closeCreateResult(result)
|
||||
return s.status[name] ?? result.status
|
||||
const result = yield* create(name, mcp)
|
||||
|
||||
s.status[name] = result.status
|
||||
if (!result.mcpClient) {
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
return result.status
|
||||
}
|
||||
|
||||
return yield* applyCreateResult(s, name, result, mcp.timeout)
|
||||
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
|
||||
})
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||
|
|
@ -735,10 +655,9 @@ export const layer = Layer.effect(
|
|||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||
yield* requireMcpConfig(name)
|
||||
const s = yield* InstanceState.get(state)
|
||||
bump(s, name)
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
yield* setStatus(s, name, { status: "disabled" })
|
||||
s.status[name] = { status: "disabled" }
|
||||
})
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { expect, mock, beforeEach } from "bun:test"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// --- Mock infrastructure ---
|
||||
|
||||
|
|
@ -27,10 +25,6 @@ let lastCreatedClientName: string | undefined
|
|||
let connectShouldFail = false
|
||||
let connectShouldHang = false
|
||||
let connectError = "Mock transport cannot connect"
|
||||
let connectHook: (() => Promise<void>) | undefined
|
||||
let activeConnects = 0
|
||||
let maxActiveConnects = 0
|
||||
let connectStarts = 0
|
||||
// Tracks how many Client instances were created (detects leaks)
|
||||
let clientCreateCount = 0
|
||||
// Tracks how many times transport.close() is called across all mock transports
|
||||
|
|
@ -58,19 +52,6 @@ function getOrCreateClientState(name?: string): MockClientState {
|
|||
return state
|
||||
}
|
||||
|
||||
async function runMockConnect() {
|
||||
activeConnects++
|
||||
connectStarts++
|
||||
maxActiveConnects = Math.max(maxActiveConnects, activeConnects)
|
||||
try {
|
||||
await connectHook?.()
|
||||
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
||||
if (connectShouldFail) throw new Error(connectError)
|
||||
} finally {
|
||||
activeConnects--
|
||||
}
|
||||
}
|
||||
|
||||
// Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
|
||||
class MockStdioTransport {
|
||||
stderr: null = null
|
||||
|
|
@ -78,7 +59,8 @@ class MockStdioTransport {
|
|||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_opts: any) {}
|
||||
async start() {
|
||||
return runMockConnect()
|
||||
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
||||
if (connectShouldFail) throw new Error(connectError)
|
||||
}
|
||||
async close() {
|
||||
transportCloseCount++
|
||||
|
|
@ -89,7 +71,8 @@ class MockStreamableHTTP {
|
|||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_url: URL, _opts?: any) {}
|
||||
async start() {
|
||||
return runMockConnect()
|
||||
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
||||
if (connectShouldFail) throw new Error(connectError)
|
||||
}
|
||||
async close() {
|
||||
transportCloseCount++
|
||||
|
|
@ -101,7 +84,8 @@ class MockSSE {
|
|||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_url: URL, _opts?: any) {}
|
||||
async start() {
|
||||
return runMockConnect()
|
||||
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
|
||||
if (connectShouldFail) throw new Error(connectError)
|
||||
}
|
||||
async close() {
|
||||
transportCloseCount++
|
||||
|
|
@ -189,10 +173,6 @@ beforeEach(() => {
|
|||
connectShouldFail = false
|
||||
connectShouldHang = false
|
||||
connectError = "Mock transport cannot connect"
|
||||
connectHook = undefined
|
||||
activeConnects = 0
|
||||
maxActiveConnects = 0
|
||||
connectStarts = 0
|
||||
clientCreateCount = 0
|
||||
transportCloseCount = 0
|
||||
})
|
||||
|
|
@ -208,91 +188,6 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
|
|||
return status[server]?.status
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
it.instance(
|
||||
"status() returns connecting without waiting for configured startup",
|
||||
() =>
|
||||
MCP.Service.use((mcp: MCPNS.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
const connect = deferred()
|
||||
connectHook = () => connect.promise
|
||||
|
||||
const status = yield* awaitWithTimeout(mcp.status(), "mcp status blocked on startup", "200 millis")
|
||||
expect(status["slow-server"]?.status).toBe("connecting")
|
||||
|
||||
yield* pollWithTimeout(
|
||||
Effect.sync(() => (connectStarts === 1 ? true : undefined)),
|
||||
"configured mcp startup did not begin",
|
||||
)
|
||||
|
||||
connect.resolve()
|
||||
|
||||
yield* pollWithTimeout(
|
||||
Effect.gen(function* () {
|
||||
const next = yield* mcp.status()
|
||||
return next["slow-server"]?.status === "connected" ? true : undefined
|
||||
}),
|
||||
"configured mcp startup did not complete",
|
||||
)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
mcp: {
|
||||
"slow-server": {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.live("configured MCP startup runs for one project at a time", () =>
|
||||
Effect.gen(function* () {
|
||||
const connect = deferred()
|
||||
connectHook = () => connect.promise
|
||||
const config = {
|
||||
mcp: {
|
||||
"slow-server": {
|
||||
type: "local" as const,
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const first = yield* tmpdirScoped({ config })
|
||||
const second = yield* tmpdirScoped({ config })
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
yield* mcp.status().pipe(provideInstance(first))
|
||||
yield* pollWithTimeout(
|
||||
Effect.sync(() => (connectStarts === 1 ? true : undefined)),
|
||||
"first configured mcp startup did not begin",
|
||||
)
|
||||
|
||||
yield* mcp.status().pipe(provideInstance(second))
|
||||
yield* Effect.sleep("100 millis")
|
||||
expect(connectStarts).toBe(1)
|
||||
expect(maxActiveConnects).toBe(1)
|
||||
|
||||
connect.resolve()
|
||||
|
||||
yield* pollWithTimeout(
|
||||
Effect.sync(() => (connectStarts === 2 && activeConnects === 0 ? true : undefined)),
|
||||
"second configured mcp startup did not run after first completed",
|
||||
)
|
||||
expect(maxActiveConnects).toBe(1)
|
||||
}).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
// Test: tools() are cached after connect
|
||||
// ========================================================================
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ export type Event =
|
|||
| EventSessionIdle
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpBrowserOpenFailed
|
||||
| EventMcpStatusChanged
|
||||
| EventCommandExecuted
|
||||
| EventProjectUpdated
|
||||
| EventSessionCompacted
|
||||
|
|
@ -353,40 +352,6 @@ export type SessionStatus =
|
|||
type: "busy"
|
||||
}
|
||||
|
||||
export type McpStatusConnected = {
|
||||
status: "connected"
|
||||
}
|
||||
|
||||
export type McpStatusDisabled = {
|
||||
status: "disabled"
|
||||
}
|
||||
|
||||
export type McpStatusConnecting = {
|
||||
status: "connecting"
|
||||
}
|
||||
|
||||
export type McpStatusFailed = {
|
||||
status: "failed"
|
||||
error: string
|
||||
}
|
||||
|
||||
export type McpStatusNeedsAuth = {
|
||||
status: "needs_auth"
|
||||
}
|
||||
|
||||
export type McpStatusNeedsClientRegistration = {
|
||||
status: "needs_client_registration"
|
||||
error: string
|
||||
}
|
||||
|
||||
export type McpStatus =
|
||||
| McpStatusConnected
|
||||
| McpStatusDisabled
|
||||
| McpStatusConnecting
|
||||
| McpStatusFailed
|
||||
| McpStatusNeedsAuth
|
||||
| McpStatusNeedsClientRegistration
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
worktree: string
|
||||
|
|
@ -865,7 +830,6 @@ export type GlobalEvent = {
|
|||
| EventSessionIdle
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpBrowserOpenFailed
|
||||
| EventMcpStatusChanged
|
||||
| EventCommandExecuted
|
||||
| EventProjectUpdated
|
||||
| EventSessionCompacted
|
||||
|
|
@ -1698,6 +1662,35 @@ export type FormatterStatus = {
|
|||
enabled: boolean
|
||||
}
|
||||
|
||||
export type McpStatusConnected = {
|
||||
status: "connected"
|
||||
}
|
||||
|
||||
export type McpStatusDisabled = {
|
||||
status: "disabled"
|
||||
}
|
||||
|
||||
export type McpStatusFailed = {
|
||||
status: "failed"
|
||||
error: string
|
||||
}
|
||||
|
||||
export type McpStatusNeedsAuth = {
|
||||
status: "needs_auth"
|
||||
}
|
||||
|
||||
export type McpStatusNeedsClientRegistration = {
|
||||
status: "needs_client_registration"
|
||||
error: string
|
||||
}
|
||||
|
||||
export type McpStatus =
|
||||
| McpStatusConnected
|
||||
| McpStatusDisabled
|
||||
| McpStatusFailed
|
||||
| McpStatusNeedsAuth
|
||||
| McpStatusNeedsClientRegistration
|
||||
|
||||
export type McpUnsupportedOAuthError = {
|
||||
error: string
|
||||
}
|
||||
|
|
@ -2679,15 +2672,6 @@ export type EventMcpBrowserOpenFailed = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventMcpStatusChanged = {
|
||||
id: string
|
||||
type: "mcp.status.changed"
|
||||
properties: {
|
||||
name: string
|
||||
status: McpStatus
|
||||
}
|
||||
}
|
||||
|
||||
export type EventCommandExecuted = {
|
||||
id: string
|
||||
type: "command.executed"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue