fix(mcp-client): await Cordis startup discovery

This commit is contained in:
Tianyi Cui
2026-08-08 21:13:20 +08:00
parent 67b9e9b1d6
commit 913ecf5f1d
18 changed files with 108 additions and 39 deletions
+21 -10
View File
@@ -72,6 +72,8 @@ export interface StdioConfig {
cwd: string
/** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
/** Fail plugin activation when the initial connection or tool discovery fails. */
failOnStartupError: boolean
}
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
@@ -90,6 +92,8 @@ export interface StreamableHttpConfig {
headers: Record<string, string>
/** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
/** Fail plugin activation when the initial connection or tool discovery fails. */
failOnStartupError: boolean
}
/** Configuration for one stdio or Streamable HTTP MCP server. */
@@ -104,6 +108,7 @@ export const Config = z.union([
env: z.dict(String).default({}),
cwd: z.string().default(''),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
failOnStartupError: z.boolean().default(false),
}),
z.object({
transport: z.const('streamable-http'),
@@ -111,6 +116,7 @@ export const Config = z.union([
url: z.string().required(),
headers: z.dict(String).default({}),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
failOnStartupError: z.boolean().default(false),
}),
]) as unknown as z<Config>
@@ -118,11 +124,13 @@ export const Config = z.union([
/**
* Connect one MCP server and publish its initial tool generation before activation.
* This entry remains explicitly `async`: Cordis treats a prototype-bearing
* ordinary function as a constructor, whose returned Promise is not startup work.
* @param ctx - plugin context carrying the tool registry.
* @param config - resolved transport and server namespace configuration.
* @returns startup readiness after connection and initial tool discovery settle.
*/
export function apply(ctx: Context, config: Config): Promise<void> {
export async function apply(ctx: Context, config: Config): Promise<void> {
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
// at load with an actionable error and leaves the earlier instance intact.
ctx.effect(() => {
@@ -151,10 +159,10 @@ export function apply(ctx: Context, config: Config): Promise<void> {
toolCallTimeoutMs: config.toolCallTimeoutMs,
}
// Connect and set up tools. Errors during connect/first sync are logged,
// not thrown (the plugin simply has no tools registered). `ready` resolves
// to an accessor for the CURRENT disposer generation, so the effect
// disposer below always unregisters the live set, not the first one.
// Connect and set up tools. `ready` always settles to an outcome so rollback
// can close a partially opened client even when strict startup later rejects.
// Its accessor returns the CURRENT disposer generation, so disposal always
// unregisters the live set, not the first one.
const ready = (async () => {
await client.connect(transport)
@@ -174,17 +182,20 @@ export function apply(ctx: Context, config: Config): Promise<void> {
},
)
return () => disposers
return { getDisposers: () => disposers }
})().catch((error: unknown) => {
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
return () => new Map<string, () => void>()
return { getDisposers: () => new Map<string, () => void>(), error }
})
ctx.effect(() => async () => {
const live = await ready
for (const dispose of live().values()) dispose()
const outcome = await ready
for (const dispose of outcome.getDisposers().values()) dispose()
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection')
return ready.then(() => undefined)
const outcome = await ready
if ('error' in outcome && config.failOnStartupError) {
throw new Error(`mcp-client(${config.serverName}): initial connection or tool discovery failed`, { cause: outcome.error })
}
}