|
|
@@ -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 })
|
|
|
+ }
|
|
|
}
|