Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions packages/core/src/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,24 @@ export namespace AppFileSystem {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
return entries.map(
(e): DirEntry => ({
name: e.name,
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
return await Promise.all(
entries.map(async (e): Promise<DirEntry> => {
let type: DirEntry["type"]
if (e.isDirectory()) {
type = "directory"
} else if (e.isSymbolicLink()) {
try {
const target = await NFS.stat(join(dirPath, e.name))
type = target.isDirectory() ? "directory" : "symlink"
} catch {
type = "symlink"
}
} else if (e.isFile()) {
type = "file"
} else {
type = "other"
}
return { name: e.name, type }
}),
)
Comment on lines +71 to 90
},
Expand Down
47 changes: 46 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
Expand Down Expand Up @@ -26,7 +28,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 } from "effect"
import { Cause, 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"
Expand Down Expand Up @@ -233,6 +235,7 @@ interface State {
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
disabledFromPersistence: Set<string>
}

export interface Interface {
Expand Down Expand Up @@ -270,6 +273,36 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service
const bus = yield* Bus.Service
const fs = yield* AppFileSystem.Service

const readDisabledState = Effect.fnUntraced(function* () {
return yield* fs.readJson(path.join(Global.Path.state, "mcp-state.json")).pipe(
Effect.map((data) => {
if (data === null || typeof data !== "object") return new Set<string>()
const obj = data as Record<string, unknown>
if (!Array.isArray(obj.disabledMcpServerIds)) return new Set<string>()
return new Set(obj.disabledMcpServerIds.filter((x): x is string => typeof x === "string"))
}),
Effect.catchCause((cause) => {
const err = Cause.squash(cause)
if (err && typeof err === "object" && "_tag" in err && err._tag === "NotFound")
return Effect.succeed(new Set<string>())
log.warn("failed to read mcp-state.json", { cause: err })
return Effect.succeed(new Set<string>())
}),
Comment on lines +286 to +292
)
})

const writeDisabledState = Effect.fnUntraced(function* (ids: Set<string>) {
yield* fs
.writeJson(path.join(Global.Path.state, "mcp-state.json"), { disabledMcpServerIds: [...ids].sort() }, 0o600)
.pipe(
Effect.catch((error) => {
log.error("failed to write mcp-state.json", { error })
return Effect.void
}),
)
})

type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport

Expand Down Expand Up @@ -515,10 +548,12 @@ export const layer = Layer.effect(
const cfg = yield* cfgSvc.get()
const bridge = yield* EffectBridge.make()
const config = cfg.mcp ?? {}
const disabled = yield* readDisabledState()
const s: State = {
status: {},
clients: {},
defs: {},
disabledFromPersistence: disabled,
}

yield* Effect.forEach(
Expand All @@ -535,6 +570,11 @@ export const layer = Layer.effect(
return
}

if (s.disabledFromPersistence.has(key)) {
s.status[key] = { status: "disabled" }
return
}

const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
if (!result) return

Expand Down Expand Up @@ -644,6 +684,9 @@ export const layer = Layer.effect(
log.error("MCP config not found or invalid", { name })
return
}
const s = yield* InstanceState.get(state)
s.disabledFromPersistence.delete(name)
yield* writeDisabledState(s.disabledFromPersistence)
yield* createAndStore(name, { ...mcp, enabled: true })
})

Expand All @@ -652,6 +695,8 @@ export const layer = Layer.effect(
yield* closeClient(s, name)
delete s.clients[name]
s.status[name] = { status: "disabled" }
s.disabledFromPersistence.add(name)
yield* writeDisabledState(s.disabledFromPersistence)
})

const tools = Effect.fn("MCP.tools")(function* () {
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/test/fixture/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "path"
import { Effect, Context, Layer, ManagedRuntime } from "effect"
import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config"
Expand Down Expand Up @@ -209,7 +210,7 @@ export const withTmpdirInstance =
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(AppFileSystem.defaultLayer), Effect.provide(CrossSpawnSpawner.defaultLayer))

export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
Expand Down
202 changes: 202 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { expect, mock, beforeEach } from "bun:test"
import { Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import path from "path"

// --- Mock infrastructure ---

Expand Down Expand Up @@ -880,3 +883,202 @@ it.instance(
),
{ config: { mcp: {} } },
)

// ========================================================================
// Persistence Tests: MCP Disabled State Persistence (mcp-state.json)
// ========================================================================

it.instance(
"disconnect persists disabled ID to mcp-state.json",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "persist-disc-server"
getOrCreateClientState("persist-disc-server")

yield* mcp.add("persist-disc-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.disconnect("persist-disc-server")

const fs = yield* AppFileSystem.Service
const data = yield* fs
.readJson(path.join(Global.Path.state, "mcp-state.json"))
.pipe(Effect.catch(() => Effect.succeed(null)))
expect(data).not.toBeNull()
const arr = (data as any).disabledMcpServerIds
expect(Array.isArray(arr)).toBe(true)
expect(arr).toContain("persist-disc-server")
}),
),
{ config: { mcp: {} } },
)
Comment on lines +891 to +916

it.instance(
"connect removes disabled ID from mcp-state.json",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "reconn-persist-server"
const serverState = getOrCreateClientState("reconn-persist-server")
serverState.tools = [
{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
]

yield* mcp.add("reconn-persist-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.disconnect("reconn-persist-server")

lastCreatedClientName = "reconn-persist-server"
yield* mcp.connect("reconn-persist-server")

const fs = yield* AppFileSystem.Service
const data = yield* fs.readJson(path.join(Global.Path.state, "mcp-state.json"))
const arr = (data as any).disabledMcpServerIds
expect(Array.isArray(arr)).toBe(true)
expect(arr).not.toContain("reconn-persist-server")
}),
),
{
config: {
mcp: {
"reconn-persist-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)

it.instance(
"init reads disabled state from pre-written mcp-state.json",
() =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filepath = path.join(Global.Path.state, "mcp-state.json")
yield* fs.remove(filepath).pipe(Effect.catch(() => Effect.void))
yield* fs.writeJson(filepath, { disabledMcpServerIds: ["pre-disabled-server"] }, 0o600)

const mcp = yield* MCP.Service
const status = yield* mcp.status()
expect(status["pre-disabled-server"]?.status).toBe("disabled")
}),
{
config: {
mcp: {
"pre-disabled-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)

it.instance(
"missing mcp-state.json defaults to all MCPs enabled",
() =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filepath = path.join(Global.Path.state, "mcp-state.json")
yield* fs.remove(filepath).pipe(Effect.catch(() => Effect.void))

lastCreatedClientName = "no-state-server"
getOrCreateClientState("no-state-server")

const mcp = yield* MCP.Service
const status = yield* mcp.status()
expect(status["no-state-server"]?.status).toBe("connected")
}),
{
config: {
mcp: {
"no-state-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)

it.instance(
"corrupt mcp-state.json defaults to all MCPs enabled",
() =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filepath = path.join(Global.Path.state, "mcp-state.json")
yield* fs.remove(filepath).pipe(Effect.catch(() => Effect.void))
yield* fs.writeFileString(filepath, "{invalid json!!!")

lastCreatedClientName = "corrupt-state-server"
getOrCreateClientState("corrupt-state-server")

const mcp = yield* MCP.Service
const status = yield* mcp.status()
expect(status["corrupt-state-server"]?.status).toBe("connected")
}),
{
config: {
mcp: {
"corrupt-state-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)

it.instance(
"config enabled:false takes precedence over persisted state",
() =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = yield* mcp.status()
expect(status["config-disabled-server"]?.status).toBe("disabled")
}),
{
config: {
mcp: {
"config-disabled-server": {
type: "local",
command: ["echo", "test"],
enabled: false,
},
},
},
},
)

it.instance(
"stale disabled ID in mcp-state.json is silently ignored",
() =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filepath = path.join(Global.Path.state, "mcp-state.json")
yield* fs.remove(filepath).pipe(Effect.catch(() => Effect.void))
yield* fs.writeJson(filepath, { disabledMcpServerIds: ["removed-mcp", "stale-id"] }, 0o600)

lastCreatedClientName = "active-server"
getOrCreateClientState("active-server")

const mcp = yield* MCP.Service
const status = yield* mcp.status()
expect(status["active-server"]?.status).toBe("connected")
}),
{
config: {
mcp: {
"active-server": {
type: "local",
command: ["echo", "test"],
},
},
},
},
)
Loading