refactor(mcp): remove async facade exports (#22324)

This commit is contained in:
Kit Langton
2026-04-13 13:45:34 -04:00
committed by GitHub
parent 329fcb040b
commit 1dc69359d5
8 changed files with 501 additions and 389 deletions

View File

@@ -15,7 +15,8 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "@/effect/app-runtime"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
@@ -51,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote"
}
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
async function listState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
}),
)
}
async function authState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
}),
)
}
export const McpCommand = cmd({
command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
@@ -76,13 +118,8 @@ export const McpListCommand = cmd({
UI.empty()
prompts.intro("MCP Servers")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const statuses = await MCP.status()
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
const { config, statuses, stored } = await listState()
const servers = configuredServers(config)
if (servers.length === 0) {
prompts.log.warn("No MCP servers configured")
@@ -93,7 +130,7 @@ export const McpListCommand = cmd({
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = await MCP.hasStoredTokens(name)
const hasStoredTokens = stored[name]
let statusIcon: string
let statusText: string
@@ -153,15 +190,11 @@ export const McpAuthCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Authentication")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const { config, auth } = await authState()
const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(`
@@ -178,19 +211,17 @@ export const McpAuthCommand = cmd({
let serverName = args.name
if (!serverName) {
// Build options with auth status
const options = await Promise.all(
oauthServers.map(async ([name, cfg]) => {
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
}),
)
const options = servers.map(([name, cfg]) => {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
})
const selected = await prompts.select({
message: "Select MCP server to authenticate",
@@ -214,7 +245,8 @@ export const McpAuthCommand = cmd({
}
// Check if already authenticated
const authStatus = await MCP.getAuthStatus(serverName)
const authStatus =
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
if (authStatus === "authenticated") {
const confirm = await prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -241,7 +273,7 @@ export const McpAuthCommand = cmd({
})
try {
const status = await MCP.authenticate(serverName)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
if (status.status === "connected") {
spinner.stop("Authentication successful!")
@@ -290,22 +322,17 @@ export const McpAuthListCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Status")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const { config, auth } = await authState()
const servers = oauthServers(config)
// Get OAuth-capable servers
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done")
return
}
for (const [name, serverConfig] of oauthServers) {
const authStatus = await MCP.getAuthStatus(name)
for (const [name, serverConfig] of servers) {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url
@@ -313,7 +340,7 @@ export const McpAuthListCommand = cmd({
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
}
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
prompts.outro(`${servers.length} OAuth-capable server(s)`)
},
})
},
@@ -335,7 +362,7 @@ export const McpLogoutCommand = cmd({
prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json")
const credentials = await McpAuth.all()
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
const serverNames = Object.keys(credentials)
if (serverNames.length === 0) {
@@ -373,7 +400,7 @@ export const McpLogoutCommand = cmd({
return
}
await MCP.removeAuth(serverName)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done")
},
@@ -623,10 +650,18 @@ export const McpDebugCommand = cmd({
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status
const authStatus = await MCP.getAuthStatus(serverName)
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
const entry = await McpAuth.get(serverName)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {

View File

@@ -27,7 +27,6 @@ import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -890,37 +889,4 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
// --- Async facade functions ---
export const status = async () => runPromise((svc) => svc.status())
export const tools = async () => runPromise((svc) => svc.tools())
export const prompts = async () => runPromise((svc) => svc.prompts())
export const resources = async () => runPromise((svc) => svc.resources())
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
}

View File

@@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.resources())
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
},
),
)

View File

@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Effect } from "effect"
export const McpRoutes = lazy(() =>
new Hono()
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.status())
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
},
)
.post(
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status)
},
)
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
auth: yield* mcp.startAuth(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
return c.json(result.auth)
},
)
.post(
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status)
},
)
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
status: yield* mcp.authenticate(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
return c.json(result.status)
},
)
.delete(
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
await MCP.removeAuth(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const })
},
)
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.connect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true)
},
)
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.disconnect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true)
},
),

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track what options were passed to each transport constructor
const transportCalls: Array<{
@@ -44,8 +46,10 @@ beforeEach(() => {
// Import MCP after mocking
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("headers are passed to transports when oauth is enabled (default)", async () => {
await using tmp = await tmpdir({
@@ -73,14 +77,21 @@ test("headers are passed to transports when oauth is enabled (default)", async (
directory: tmp.path,
fn: async () => {
// Trigger MCP initialization - it will fail to connect but we can check the transport options
await MCP.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server", {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer test-token",
"X-Custom-Header": "custom-value",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
// Both transports should have been created with headers
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -106,14 +117,21 @@ test("headers are passed to transports when oauth is explicitly disabled", async
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server-no-oauth", {
type: "remote",
url: "https://example.com/mcp",
oauth: false,
headers: {
Authorization: "Bearer test-token",
},
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -137,10 +155,17 @@ test("no requestInit when headers are not provided", async () => {
fn: async () => {
transportCalls.length = 0
await MCP.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
}).catch(() => {})
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
yield* mcp
.add("test-server-no-headers", {
type: "remote",
url: "https://example.com/mcp",
})
.pipe(Effect.catch(() => Effect.void))
}),
)
expect(transportCalls.length).toBeGreaterThanOrEqual(1)

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// --- Mock infrastructure ---
@@ -170,7 +172,10 @@ const { tmpdir } = await import("../fixture/fixture")
// --- Helper ---
function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
function withInstance(
config: Record<string, unknown>,
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
) {
return async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -187,7 +192,7 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await fn()
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
// dispose instance to clean up state between tests
await Instance.dispose()
},
@@ -201,28 +206,30 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
test(
"tools() reuses cached tool definitions after connect",
withInstance({}, async () => {
lastCreatedClientName = "my-server"
const serverState = getOrCreateClientState("my-server")
serverState.tools = [
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
]
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "my-server"
const serverState = getOrCreateClientState("my-server")
serverState.tools = [
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
]
// First: add the server successfully
const addResult = await MCP.add("my-server", {
type: "local",
command: ["echo", "test"],
})
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
// First: add the server successfully
const addResult = yield* mcp.add("my-server", {
type: "local",
command: ["echo", "test"],
})
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
expect(serverState.listToolsCalls).toBe(1)
expect(serverState.listToolsCalls).toBe(1)
const toolsA = await MCP.tools()
const toolsB = await MCP.tools()
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
expect(serverState.listToolsCalls).toBe(1)
}),
const toolsA = yield* mcp.tools()
const toolsB = yield* mcp.tools()
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
expect(serverState.listToolsCalls).toBe(1)
}),
),
)
// ========================================================================
@@ -231,30 +238,32 @@ test(
test(
"tool change notifications refresh cached tool definitions",
withInstance({}, async () => {
lastCreatedClientName = "status-server"
const serverState = getOrCreateClientState("status-server")
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "status-server"
const serverState = getOrCreateClientState("status-server")
await MCP.add("status-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("status-server", {
type: "local",
command: ["echo", "test"],
})
const before = await MCP.tools()
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
const before = yield* mcp.tools()
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
const handler = Array.from(serverState.notificationHandlers.values())[0]
expect(handler).toBeDefined()
await handler?.()
const handler = Array.from(serverState.notificationHandlers.values())[0]
expect(handler).toBeDefined()
yield* Effect.promise(() => handler?.())
const after = await MCP.tools()
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
expect(serverState.listToolsCalls).toBe(2)
}),
const after = yield* mcp.tools()
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
expect(serverState.listToolsCalls).toBe(2)
}),
),
)
// ========================================================================
@@ -270,28 +279,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "disc-server"
getOrCreateClientState("disc-server")
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "disc-server"
getOrCreateClientState("disc-server")
await MCP.add("disc-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("disc-server", {
type: "local",
command: ["echo", "test"],
})
const statusBefore = await MCP.status()
expect(statusBefore["disc-server"]?.status).toBe("connected")
const statusBefore = yield* mcp.status()
expect(statusBefore["disc-server"]?.status).toBe("connected")
await MCP.disconnect("disc-server")
yield* mcp.disconnect("disc-server")
const statusAfter = await MCP.status()
expect(statusAfter["disc-server"]?.status).toBe("disabled")
const statusAfter = yield* mcp.status()
expect(statusAfter["disc-server"]?.status).toBe("disabled")
// Tools should be empty after disconnect
const tools = await MCP.tools()
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
expect(serverTools.length).toBe(0)
},
// Tools should be empty after disconnect
const tools = yield* mcp.tools()
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
expect(serverTools.length).toBe(0)
}),
),
)
@@ -304,26 +314,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "reconn-server"
const serverState = getOrCreateClientState("reconn-server")
serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "reconn-server"
const serverState = getOrCreateClientState("reconn-server")
serverState.tools = [
{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
]
await MCP.add("reconn-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("reconn-server", {
type: "local",
command: ["echo", "test"],
})
await MCP.disconnect("reconn-server")
expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
yield* mcp.disconnect("reconn-server")
expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
// Reconnect
await MCP.connect("reconn-server")
expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
// Reconnect
yield* mcp.connect("reconn-server")
expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
const tools = await MCP.tools()
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
},
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
}),
),
)
@@ -335,30 +348,32 @@ test(
"add() closes the old client when replacing a server",
// Don't put the server in config — add it dynamically so we control
// exactly which client instance is "first" vs "second".
withInstance({}, async () => {
lastCreatedClientName = "replace-server"
const firstState = getOrCreateClientState("replace-server")
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "replace-server"
const firstState = getOrCreateClientState("replace-server")
await MCP.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
expect(firstState.closed).toBe(false)
expect(firstState.closed).toBe(false)
// Create new state for second client
clientStates.delete("replace-server")
const secondState = getOrCreateClientState("replace-server")
// Create new state for second client
clientStates.delete("replace-server")
const secondState = getOrCreateClientState("replace-server")
// Re-add should close the first client
await MCP.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
// Re-add should close the first client
yield* mcp.add("replace-server", {
type: "local",
command: ["echo", "test"],
})
expect(firstState.closed).toBe(true)
expect(secondState.closed).toBe(false)
}),
expect(firstState.closed).toBe(true)
expect(secondState.closed).toBe(false)
}),
),
)
// ========================================================================
@@ -378,37 +393,38 @@ test(
command: ["echo", "bad"],
},
},
async () => {
// Set up good server
const goodState = getOrCreateClientState("good-server")
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
(mcp) =>
Effect.gen(function* () {
// Set up good server
const goodState = getOrCreateClientState("good-server")
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
// Set up bad server - will fail on listTools during create()
const badState = getOrCreateClientState("bad-server")
badState.listToolsShouldFail = true
// Set up bad server - will fail on listTools during create()
const badState = getOrCreateClientState("bad-server")
badState.listToolsShouldFail = true
// Add good server first
lastCreatedClientName = "good-server"
await MCP.add("good-server", {
type: "local",
command: ["echo", "good"],
})
// Add good server first
lastCreatedClientName = "good-server"
yield* mcp.add("good-server", {
type: "local",
command: ["echo", "good"],
})
// Add bad server - should fail but not affect good server
lastCreatedClientName = "bad-server"
await MCP.add("bad-server", {
type: "local",
command: ["echo", "bad"],
})
// Add bad server - should fail but not affect good server
lastCreatedClientName = "bad-server"
yield* mcp.add("bad-server", {
type: "local",
command: ["echo", "bad"],
})
const status = await MCP.status()
expect(status["good-server"]?.status).toBe("connected")
expect(status["bad-server"]?.status).toBe("failed")
const status = yield* mcp.status()
expect(status["good-server"]?.status).toBe("connected")
expect(status["bad-server"]?.status).toBe("failed")
// Good server's tools should still be available
const tools = await MCP.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
},
// Good server's tools should still be available
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
}),
),
)
@@ -426,21 +442,22 @@ test(
enabled: false,
},
},
async () => {
const countBefore = clientCreateCount
(mcp) =>
Effect.gen(function* () {
const countBefore = clientCreateCount
await MCP.add("disabled-server", {
type: "local",
command: ["echo", "test"],
enabled: false,
} as any)
yield* mcp.add("disabled-server", {
type: "local",
command: ["echo", "test"],
enabled: false,
} as any)
// No client should have been created
expect(clientCreateCount).toBe(countBefore)
// No client should have been created
expect(clientCreateCount).toBe(countBefore)
const status = await MCP.status()
expect(status["disabled-server"]?.status).toBe("disabled")
},
const status = yield* mcp.status()
expect(status["disabled-server"]?.status).toBe("disabled")
}),
),
)
@@ -457,22 +474,23 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "prompt-server"
const serverState = getOrCreateClientState("prompt-server")
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "prompt-server"
const serverState = getOrCreateClientState("prompt-server")
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
await MCP.add("prompt-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("prompt-server", {
type: "local",
command: ["echo", "test"],
})
const prompts = await MCP.prompts()
expect(Object.keys(prompts).length).toBe(1)
const key = Object.keys(prompts)[0]
expect(key).toContain("prompt-server")
expect(key).toContain("my-prompt")
},
const prompts = yield* mcp.prompts()
expect(Object.keys(prompts).length).toBe(1)
const key = Object.keys(prompts)[0]
expect(key).toContain("prompt-server")
expect(key).toContain("my-prompt")
}),
),
)
@@ -485,22 +503,23 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "resource-server"
const serverState = getOrCreateClientState("resource-server")
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
await MCP.add("resource-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("resource-server", {
type: "local",
command: ["echo", "test"],
})
const resources = await MCP.resources()
expect(Object.keys(resources).length).toBe(1)
const key = Object.keys(resources)[0]
expect(key).toContain("resource-server")
expect(key).toContain("my-resource")
},
const resources = yield* mcp.resources()
expect(Object.keys(resources).length).toBe(1)
const key = Object.keys(resources)[0]
expect(key).toContain("resource-server")
expect(key).toContain("my-resource")
}),
),
)
@@ -513,21 +532,22 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "prompt-disc-server"
const serverState = getOrCreateClientState("prompt-disc-server")
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "prompt-disc-server"
const serverState = getOrCreateClientState("prompt-disc-server")
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
await MCP.add("prompt-disc-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("prompt-disc-server", {
type: "local",
command: ["echo", "test"],
})
await MCP.disconnect("prompt-disc-server")
yield* mcp.disconnect("prompt-disc-server")
const prompts = await MCP.prompts()
expect(Object.keys(prompts).length).toBe(0)
},
const prompts = yield* mcp.prompts()
expect(Object.keys(prompts).length).toBe(0)
}),
),
)
@@ -537,12 +557,14 @@ test(
test(
"connect() on nonexistent server does not throw",
withInstance({}, async () => {
// Should not throw
await MCP.connect("nonexistent")
const status = await MCP.status()
expect(status["nonexistent"]).toBeUndefined()
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
// Should not throw
yield* mcp.connect("nonexistent")
const status = yield* mcp.status()
expect(status["nonexistent"]).toBeUndefined()
}),
),
)
// ========================================================================
@@ -551,10 +573,12 @@ test(
test(
"disconnect() on nonexistent server does not throw",
withInstance({}, async () => {
await MCP.disconnect("nonexistent")
// Should complete without error
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
yield* mcp.disconnect("nonexistent")
// Should complete without error
}),
),
)
// ========================================================================
@@ -563,10 +587,12 @@ test(
test(
"tools() returns empty when no MCP servers are configured",
withInstance({}, async () => {
const tools = await MCP.tools()
expect(Object.keys(tools).length).toBe(0)
}),
withInstance({}, (mcp) =>
Effect.gen(function* () {
const tools = yield* mcp.tools()
expect(Object.keys(tools).length).toBe(0)
}),
),
)
// ========================================================================
@@ -582,27 +608,28 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "fail-connect"
getOrCreateClientState("fail-connect")
connectShouldFail = true
connectError = "Connection refused"
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "fail-connect"
getOrCreateClientState("fail-connect")
connectShouldFail = true
connectError = "Connection refused"
await MCP.add("fail-connect", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("fail-connect", {
type: "local",
command: ["echo", "test"],
})
const status = await MCP.status()
expect(status["fail-connect"]?.status).toBe("failed")
if (status["fail-connect"]?.status === "failed") {
expect(status["fail-connect"].error).toContain("Connection refused")
}
const status = yield* mcp.status()
expect(status["fail-connect"]?.status).toBe("failed")
if (status["fail-connect"]?.status === "failed") {
expect(status["fail-connect"].error).toContain("Connection refused")
}
// No tools should be available
const tools = await MCP.tools()
expect(Object.keys(tools).length).toBe(0)
},
// No tools should be available
const tools = yield* mcp.tools()
expect(Object.keys(tools).length).toBe(0)
}),
),
)
@@ -648,28 +675,29 @@ test(
command: ["echo", "test"],
},
},
async () => {
lastCreatedClientName = "my.special-server"
const serverState = getOrCreateClientState("my.special-server")
serverState.tools = [
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
]
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "my.special-server"
const serverState = getOrCreateClientState("my.special-server")
serverState.tools = [
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
]
await MCP.add("my.special-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.add("my.special-server", {
type: "local",
command: ["echo", "test"],
})
const tools = await MCP.tools()
const keys = Object.keys(tools)
const tools = yield* mcp.tools()
const keys = Object.keys(tools)
// Server name dots should be replaced with underscores
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
// Tool name dots should be replaced with underscores
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
expect(keys.length).toBe(2)
},
// Server name dots should be replaced with underscores
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
// Tool name dots should be replaced with underscores
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
expect(keys.length).toBe(2)
}),
),
)
@@ -679,23 +707,25 @@ test(
test(
"local stdio transport is closed when connect times out (no process leak)",
withInstance({}, async () => {
lastCreatedClientName = "hanging-server"
getOrCreateClientState("hanging-server")
connectShouldHang = true
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "hanging-server"
getOrCreateClientState("hanging-server")
connectShouldHang = true
const addResult = await MCP.add("hanging-server", {
type: "local",
command: ["node", "fake.js"],
timeout: 100,
})
const addResult = yield* mcp.add("hanging-server", {
type: "local",
command: ["node", "fake.js"],
timeout: 100,
})
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
expect(serverStatus.error).toContain("timed out")
// Transport must be closed to avoid orphaned child process
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
expect(serverStatus.error).toContain("timed out")
// Transport must be closed to avoid orphaned child process
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
),
)
// ========================================================================
@@ -704,23 +734,25 @@ test(
test(
"remote transport is closed when connect times out",
withInstance({}, async () => {
lastCreatedClientName = "hanging-remote"
getOrCreateClientState("hanging-remote")
connectShouldHang = true
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "hanging-remote"
getOrCreateClientState("hanging-remote")
connectShouldHang = true
const addResult = await MCP.add("hanging-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 100,
oauth: false,
})
const addResult = yield* mcp.add("hanging-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 100,
oauth: false,
})
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Transport must be closed to avoid leaked HTTP connections
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Transport must be closed to avoid leaked HTTP connections
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
}),
),
)
// ========================================================================
@@ -729,22 +761,24 @@ test(
test(
"failed remote transport is closed before trying next transport",
withInstance({}, async () => {
lastCreatedClientName = "fail-remote"
getOrCreateClientState("fail-remote")
connectShouldFail = true
connectError = "Connection refused"
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "fail-remote"
getOrCreateClientState("fail-remote")
connectShouldFail = true
connectError = "Connection refused"
const addResult = await MCP.add("fail-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 5000,
oauth: false,
})
const addResult = yield* mcp.add("fail-remote", {
type: "remote",
url: "http://localhost:9999/mcp",
timeout: 5000,
oauth: false,
})
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Both StreamableHTTP and SSE transports should be closed
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
}),
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
expect(serverStatus.status).toBe("failed")
// Both StreamableHTTP and SSE transports should be closed
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
}),
),
)

View File

@@ -1,4 +1,6 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Mock UnauthorizedError to match the SDK's class
class MockUnauthorizedError extends Error {
@@ -122,10 +124,14 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await MCP.add("test-oauth", {
type: "remote",
url: "https://example.com/mcp",
})
const result = await Effect.runPromise(
MCP.Service.use((mcp) =>
mcp.add("test-oauth", {
type: "remote",
url: "https://example.com/mcp",
}),
).pipe(Effect.provide(MCP.defaultLayer)),
)
const serverStatus = result.status as Record<string, { status: string; error?: string }>

View File

@@ -1,5 +1,7 @@
import { test, expect, mock, beforeEach } from "bun:test"
import { EventEmitter } from "events"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// Track open() calls and control failure behavior
let openShouldFail = false
@@ -100,10 +102,12 @@ beforeEach(() => {
// Import modules after mocking
const { MCP } = await import("../../src/mcp/index")
const { AppRuntime } = await import("../../src/effect/app-runtime")
const { Bus } = await import("../../src/bus")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { Instance } = await import("../../src/project/instance")
const { tmpdir } = await import("../fixture/fixture")
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
test("BrowserOpenFailed event is published when open() throws", async () => {
await using tmp = await tmpdir({
@@ -136,7 +140,12 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
// Run authenticate with a timeout to avoid waiting forever for the callback
// Attach a handler immediately so callback shutdown rejections
// don't show up as unhandled between tests.
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server")
}),
).catch(() => undefined)
// Config.get() can be slow in tests, so give it plenty of time.
await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -185,7 +194,12 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
})
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-2")
}),
).catch(() => undefined)
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -230,7 +244,12 @@ test("open() is called with the authorization URL", async () => {
openCalledWith = undefined
// Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
const authPromise = AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* service
return yield* mcp.authenticate("test-oauth-server-3")
}),
).catch(() => undefined)
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 2_000))