fix(mcp): close transport on failed/timed-out connections (#19200)

This commit is contained in:
Kit Langton 2026-03-26 14:41:00 -04:00 committed by GitHub
commit 2e6ac8ff49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 350 additions and 274 deletions

View file

@ -24,7 +24,7 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open" import open from "open"
import { Effect, Layer, Option, ServiceMap, Stream } from "effect" import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@ -129,6 +129,8 @@ export namespace MCP {
return typeof entry === "object" && entry !== null && "type" in entry return typeof entry === "object" && entry !== null && "type" in entry
} }
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
// Convert MCP tool definition to AI SDK Tool type // Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema const inputSchema = mcpTool.inputSchema
@ -160,233 +162,48 @@ export namespace MCP {
}) })
} }
async function defs(key: string, client: MCPClient, timeout?: number) { function defs(key: string, client: MCPClient, timeout?: number) {
const result = await withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT).catch((err) => { return Effect.tryPromise({
log.error("failed to get tools from client", { key, error: err }) try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
return undefined catch: (err) => err instanceof Error ? err : new Error(String(err)),
}) }).pipe(
return result?.tools Effect.map((result) => result.tools),
Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined)
}),
)
} }
async function fetchFromClient<T extends { name: string }>( function fetchFromClient<T extends { name: string }>(
clientName: string, clientName: string,
client: Client, client: Client,
listFn: (c: Client) => Promise<T[]>, listFn: (c: Client) => Promise<T[]>,
label: string, label: string,
): Promise<Record<string, T & { client: string }> | undefined> { ) {
const items = await listFn(client).catch((e: any) => { return Effect.tryPromise({
log.error(`failed to get ${label}`, { clientName, error: e.message }) try: () => listFn(client),
return undefined catch: (e: any) => {
}) log.error(`failed to get ${label}`, { clientName, error: e.message })
if (!items) return undefined return e
},
const out: Record<string, T & { client: string }> = {} }).pipe(
const sanitizedClient = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") Effect.map((items) => {
for (const item of items) { const out: Record<string, T & { client: string }> = {}
const sanitizedName = item.name.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedClient = sanitize(clientName)
out[sanitizedClient + ":" + sanitizedName] = { ...item, client: clientName } for (const item of items) {
} out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName }
return out }
return out
}),
Effect.orElseSucceed(() => undefined),
)
} }
async function create(key: string, mcp: Config.Mcp) { interface CreateResult {
if (mcp.enabled === false) { mcpClient?: MCPClient
log.info("mcp server disabled", { key }) status: Status
return { defs?: MCPToolDef[]
mcpClient: undefined,
status: { status: "disabled" as const },
}
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined = undefined
if (mcp.type === "remote") {
// OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
authProvider = new McpOAuthProvider(
key,
mcp.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
},
{
onRedirect: async (url) => {
log.info("oauth redirect requested", { key, url: url.toString() })
// Store the URL - actual browser opening is handled by startAuth
},
},
)
}
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
]
let lastError: Error | undefined
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
for (const { name, transport } of transports) {
try {
const client = new Client({
name: "opencode",
version: Installation.VERSION,
})
await withTimeout(client.connect(transport), connectTimeout)
mcpClient = client
log.info("connected", { key, transport: name })
status = { status: "connected" }
break
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// Handle OAuth-specific errors.
// The SDK throws UnauthorizedError when auth() returns 'REDIRECT',
// but may also throw plain Errors when auth() fails internally
// (e.g. during discovery, registration, or state generation).
// When an authProvider is attached, treat both cases as auth-related.
const isAuthError =
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
if (isAuthError) {
log.info("mcp server requires authentication", { key, transport: name })
// Check if this is a "needs registration" error
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
status = {
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
}
// Show toast for needs_client_registration
Bus.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
variant: "warning",
duration: 8000,
}).catch((e) => log.debug("failed to show toast", { error: e }))
} else {
// Store transport for later finishAuth call
pendingOAuthTransports.set(key, transport)
status = { status: "needs_auth" as const }
// Show toast for needs_auth
Bus.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
variant: "warning",
duration: 8000,
}).catch((e) => log.debug("failed to show toast", { error: e }))
}
break
}
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
status = {
status: "failed" as const,
error: lastError.message,
}
}
}
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
args,
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
})
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })
})
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
try {
const client = new Client({
name: "opencode",
version: Installation.VERSION,
})
await withTimeout(client.connect(transport), connectTimeout)
mcpClient = client
status = {
status: "connected",
}
} catch (error) {
log.error("local mcp startup failed", {
key,
command: mcp.command,
cwd,
error: error instanceof Error ? error.message : String(error),
})
status = {
status: "failed" as const,
error: error instanceof Error ? error.message : String(error),
}
}
}
if (!status) {
status = {
status: "failed" as const,
error: "Unknown error",
}
}
if (!mcpClient) {
return {
mcpClient: undefined,
status,
}
}
const listed = await defs(key, mcpClient, mcp.timeout)
if (!listed) {
await mcpClient.close().catch((error) => {
log.error("Failed to close MCP client", {
error,
})
})
return {
mcpClient: undefined,
status: { status: "failed" as const, error: "Failed to get tools" },
}
}
log.info("create() successfully created client", { key, toolCount: listed.length })
return {
mcpClient,
status,
defs: listed,
}
} }
// --- Effect Service --- // --- Effect Service ---
@ -431,6 +248,184 @@ export namespace MCP {
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service const auth = yield* McpAuth.Service
const bus = yield* Bus.Service
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
/**
* Connect a client via the given transport with resource safety:
* on failure the transport is closed; on success the caller owns it.
*/
const connectTransport = (transport: Transport, timeout: number) =>
Effect.acquireUseRelease(
Effect.succeed(transport),
(t) =>
Effect.tryPromise({
try: () => {
const client = new Client({ name: "opencode", version: Installation.VERSION })
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
}),
(t, exit) =>
Exit.isFailure(exit)
? Effect.tryPromise(() => t.close()).pipe(Effect.ignore)
: Effect.void,
)
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
const connectRemote = Effect.fn("MCP.connectRemote")(function* (key: string, mcp: Config.Mcp & { type: "remote" }) {
const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
authProvider = new McpOAuthProvider(
key,
mcp.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
},
{
onRedirect: async (url) => {
log.info("oauth redirect requested", { key, url: url.toString() })
},
},
)
}
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
]
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
let lastStatus: Status | undefined
for (const { name, transport } of transports) {
const result = yield* connectTransport(transport, connectTimeout).pipe(
Effect.map((client) => ({ client, transportName: name })),
Effect.catch((error) => {
const lastError = error instanceof Error ? error : new Error(String(error))
const isAuthError =
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
if (isAuthError) {
log.info("mcp server requires authentication", { key, transport: name })
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
lastStatus = {
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
}
return bus.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
variant: "warning",
duration: 8000,
}).pipe(Effect.ignore, Effect.as(undefined))
} else {
pendingOAuthTransports.set(key, transport)
lastStatus = { status: "needs_auth" as const }
return bus.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
variant: "warning",
duration: 8000,
}).pipe(Effect.ignore, Effect.as(undefined))
}
}
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
lastStatus = { status: "failed" as const, error: lastError.message }
return Effect.succeed(undefined)
}),
)
if (result) {
log.info("connected", { key, transport: result.transportName })
return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status }
}
// If this was an auth error, stop trying other transports
if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break
}
return { client: undefined as MCPClient | undefined, status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status }
})
const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
args,
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
})
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })
})
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
return yield* connectTransport(transport, connectTimeout).pipe(
Effect.map((client): { client: MCPClient | undefined; status: Status } => ({ client, status: { status: "connected" } })),
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
const msg = error instanceof Error ? error.message : String(error)
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
}),
)
})
const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
return DISABLED_RESULT
}
log.info("found", { key, type: mcp.type })
const { client: mcpClient, status } = mcp.type === "remote"
? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" })
: yield* connectLocal(key, mcp as Config.Mcp & { type: "local" })
if (!mcpClient) {
return { status } satisfies CreateResult
}
const listed = yield* defs(key, mcpClient, mcp.timeout)
if (!listed) {
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
}
log.info("create() successfully created client", { key, toolCount: listed.length })
return { mcpClient, status, defs: listed } satisfies CreateResult
})
const descendants = Effect.fnUntraced( const descendants = Effect.fnUntraced(
function* (pid: number) { function* (pid: number) {
@ -463,20 +458,20 @@ export namespace MCP {
log.info("tools list changed notification received", { server: name }) log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await defs(name, client, timeout) const listed = await Effect.runPromise(defs(name, client, timeout))
if (!listed) return if (!listed) return
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
s.defs[name] = listed s.defs[name] = listed
await Bus.publish(ToolsChanged, { server: name }).catch((error) => await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
log.warn("failed to publish tools changed", { server: name, error }),
)
}) })
} }
const getConfig = () => Effect.promise(() => Config.get())
const cache = yield* InstanceState.make<State>( const cache = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () { Effect.fn("MCP.state")(function* () {
const cfg = yield* Effect.promise(() => Config.get()) const cfg = yield* getConfig()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const s: State = { const s: State = {
status: {}, status: {},
@ -498,13 +493,15 @@ export namespace MCP {
return return
} }
const result = yield* Effect.promise(() => create(key, mcp).catch(() => undefined)) const result = yield* create(key, mcp).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
if (!result) return if (!result) return
s.status[key] = result.status s.status[key] = result.status
if (result.mcpClient) { if (result.mcpClient) {
s.clients[key] = result.mcpClient s.clients[key] = result.mcpClient
s.defs[key] = result.defs s.defs[key] = result.defs!
watch(s, key, result.mcpClient, mcp.timeout) watch(s, key, result.mcpClient, mcp.timeout)
} }
}), }),
@ -542,14 +539,12 @@ export namespace MCP {
const client = s.clients[name] const client = s.clients[name]
delete s.defs[name] delete s.defs[name]
if (!client) return Effect.void if (!client) return Effect.void
return Effect.promise(() => return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
client.close().catch((error: any) => log.error("failed to close MCP client", { name, error })),
)
} }
const status = Effect.fn("MCP.status")(function* () { const status = Effect.fn("MCP.status")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(cache)
const cfg = yield* Effect.promise(() => Config.get()) const cfg = yield* getConfig()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const result: Record<string, Status> = {} const result: Record<string, Status> = {}
@ -568,14 +563,7 @@ export namespace MCP {
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) { const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(cache)
const result = yield* Effect.promise(() => create(name, mcp)) const result = yield* create(name, mcp)
if (!result) {
yield* closeClient(s, name)
delete s.clients[name]
s.status[name] = { status: "failed" as const, error: "unknown error" }
return s.status[name]
}
s.status[name] = result.status s.status[name] = result.status
if (!result.mcpClient) { if (!result.mcpClient) {
@ -586,7 +574,7 @@ export namespace MCP {
yield* closeClient(s, name) yield* closeClient(s, name)
s.clients[name] = result.mcpClient s.clients[name] = result.mcpClient
s.defs[name] = result.defs s.defs[name] = result.defs!
watch(s, name, result.mcpClient, mcp.timeout) watch(s, name, result.mcpClient, mcp.timeout)
return result.status return result.status
}) })
@ -616,7 +604,7 @@ export namespace MCP {
const tools = Effect.fn("MCP.tools")(function* () { const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, Tool> = {} const result: Record<string, Tool> = {}
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(cache)
const cfg = yield* Effect.promise(() => Config.get()) const cfg = yield* getConfig()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
const defaultTimeout = cfg.experimental?.mcp_timeout const defaultTimeout = cfg.experimental?.mcp_timeout
@ -639,9 +627,7 @@ export namespace MCP {
const timeout = entry?.timeout ?? defaultTimeout const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of listed) { for (const mcpTool of listed) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client, timeout)
} }
}), }),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
@ -649,30 +635,29 @@ export namespace MCP {
return result return result
}) })
function collectFromConnected<T>( function collectFromConnected<T extends { name: string }>(
s: State, s: State,
fetchFn: (clientName: string, client: Client) => Promise<Record<string, T> | undefined>, listFn: (c: Client) => Promise<T[]>,
label: string,
) { ) {
return Effect.forEach( return Effect.forEach(
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"), Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
([clientName, client]) => ([clientName, client]) =>
Effect.promise(async () => Object.entries((await fetchFn(clientName, client)) ?? {})), fetchFromClient(clientName, client, listFn, label).pipe(
Effect.map((items) => Object.entries(items ?? {})),
),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
).pipe(Effect.map((results) => Object.fromEntries<T>(results.flat()))) ).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
} }
const prompts = Effect.fn("MCP.prompts")(function* () { const prompts = Effect.fn("MCP.prompts")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(cache)
return yield* collectFromConnected(s, (name, client) => return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
fetchFromClient(name, client, (c) => c.listPrompts().then((r) => r.prompts), "prompts"),
)
}) })
const resources = Effect.fn("MCP.resources")(function* () { const resources = Effect.fn("MCP.resources")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(cache)
return yield* collectFromConnected(s, (name, client) => return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
fetchFromClient(name, client, (c) => c.listResources().then((r) => r.resources), "resources"),
)
}) })
const withClient = Effect.fnUntraced(function* <A>( const withClient = Effect.fnUntraced(function* <A>(
@ -713,7 +698,7 @@ export namespace MCP {
}) })
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
const cfg = yield* Effect.promise(() => Config.get()) const cfg = yield* getConfig()
const mcpConfig = cfg.mcp?.[mcpName] const mcpConfig = cfg.mcp?.[mcpName]
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
return mcpConfig return mcpConfig
@ -750,19 +735,21 @@ export namespace MCP {
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
return yield* Effect.promise(async () => { return yield* Effect.tryPromise({
try { try: () => {
const client = new Client({ name: "opencode", version: Installation.VERSION }) const client = new Client({ name: "opencode", version: Installation.VERSION })
await client.connect(transport) return client.connect(transport).then(() => ({ authorizationUrl: "", oauthState }))
return { authorizationUrl: "", oauthState } },
} catch (error) { catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof UnauthorizedError && capturedUrl) { if (error instanceof UnauthorizedError && capturedUrl) {
pendingOAuthTransports.set(mcpName, transport) pendingOAuthTransports.set(mcpName, transport)
return { authorizationUrl: capturedUrl.toString(), oauthState } return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState })
} }
throw error return Effect.die(error)
} }),
}) )
}) })
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
@ -791,7 +778,7 @@ export namespace MCP {
), ),
Effect.catch(() => { Effect.catch(() => {
log.warn("failed to open browser, user must open URL manually", { mcpName }) log.warn("failed to open browser, user must open URL manually", { mcpName })
return Effect.promise(() => Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })) return bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }).pipe(Effect.ignore)
}), }),
) )
@ -811,10 +798,7 @@ export namespace MCP {
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const result = yield* Effect.tryPromise({ const result = yield* Effect.tryPromise({
try: async () => { try: () => transport.finishAuth(authorizationCode).then(() => true as const),
await transport.finishAuth(authorizationCode)
return true
},
catch: (error) => { catch: (error) => {
log.error("failed to finish oauth", { mcpName, error }) log.error("failed to finish oauth", { mcpName, error })
return error return error
@ -887,6 +871,7 @@ export namespace MCP {
const defaultLayer = layer.pipe( const defaultLayer = layer.pipe(
Layer.provide(McpAuth.layer), Layer.provide(McpAuth.layer),
Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.layer), Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),

View file

@ -19,9 +19,12 @@ interface MockClientState {
const clientStates = new Map<string, MockClientState>() const clientStates = new Map<string, MockClientState>()
let lastCreatedClientName: string | undefined let lastCreatedClientName: string | undefined
let connectShouldFail = false let connectShouldFail = false
let connectShouldHang = false
let connectError = "Mock transport cannot connect" let connectError = "Mock transport cannot connect"
// Tracks how many Client instances were created (detects leaks) // Tracks how many Client instances were created (detects leaks)
let clientCreateCount = 0 let clientCreateCount = 0
// Tracks how many times transport.close() is called across all mock transports
let transportCloseCount = 0
function getOrCreateClientState(name?: string): MockClientState { function getOrCreateClientState(name?: string): MockClientState {
const key = name ?? "default" const key = name ?? "default"
@ -44,32 +47,41 @@ function getOrCreateClientState(name?: string): MockClientState {
return state return state
} }
// Mock transport that succeeds or fails based on connectShouldFail // Mock transport that succeeds or fails based on connectShouldFail / connectShouldHang
class MockStdioTransport { class MockStdioTransport {
stderr: null = null stderr: null = null
pid = 12345 pid = 12345
constructor(_opts: any) {} constructor(_opts: any) {}
async start() { async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError) if (connectShouldFail) throw new Error(connectError)
} }
async close() {} async close() {
transportCloseCount++
}
} }
class MockStreamableHTTP { class MockStreamableHTTP {
constructor(_url: URL, _opts?: any) {} constructor(_url: URL, _opts?: any) {}
async start() { async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError) if (connectShouldFail) throw new Error(connectError)
} }
async close() {} async close() {
transportCloseCount++
}
async finishAuth() {} async finishAuth() {}
} }
class MockSSE { class MockSSE {
constructor(_url: URL, _opts?: any) {} constructor(_url: URL, _opts?: any) {}
async start() { async start() {
throw new Error("SSE fallback - not used in these tests") if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
if (connectShouldFail) throw new Error(connectError)
}
async close() {
transportCloseCount++
} }
async close() {}
} }
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({ mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
@ -145,8 +157,10 @@ beforeEach(() => {
clientStates.clear() clientStates.clear()
lastCreatedClientName = undefined lastCreatedClientName = undefined
connectShouldFail = false connectShouldFail = false
connectShouldHang = false
connectError = "Mock transport cannot connect" connectError = "Mock transport cannot connect"
clientCreateCount = 0 clientCreateCount = 0
transportCloseCount = 0
}) })
// Import after mocks // Import after mocks
@ -658,3 +672,80 @@ test(
}, },
), ),
) )
// ========================================================================
// Test: transport leak — local stdio timeout (#19168)
// ========================================================================
test(
"local stdio transport is closed when connect times out (no process leak)",
withInstance({}, async () => {
lastCreatedClientName = "hanging-server"
getOrCreateClientState("hanging-server")
connectShouldHang = true
const addResult = await 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)
}),
)
// ========================================================================
// Test: transport leak — remote timeout (#19168)
// ========================================================================
test(
"remote transport is closed when connect times out",
withInstance({}, async () => {
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 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)
}),
)
// ========================================================================
// Test: transport leak — failed remote transports not closed (#19168)
// ========================================================================
test(
"failed remote transport is closed before trying next transport",
withInstance({}, async () => {
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 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)
}),
)