fix(mcp-client): await Cordis startup discovery
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md
|
||||
README.md: 6bcc195e36d24d7e5ae3462573b30f1b41c963a7
|
||||
README.zh.md: 8886c16c3fe66d283ae2191661118729a89f1aeb
|
||||
README.md: c87255917a2def0aef938af9f2c910b65d5a96d5
|
||||
README.zh.md: 6fd6df39d7c5034795021d41de57f8500cbfd1c5
|
||||
|
||||
@@ -44,6 +44,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same
|
||||
| `url` | http | yes | MCP server URL |
|
||||
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
||||
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
||||
| `failOnStartupError` | both | no | Reject plugin activation when the initial connection or tool discovery fails (default `false`) |
|
||||
|
||||
## Tool naming
|
||||
|
||||
@@ -56,7 +57,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
|
||||
## Behavior
|
||||
|
||||
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection failure is logged and activates with no tools.
|
||||
- On connect: plugin activation awaits `listTools()` and registers each tool via `ctx.tools.register()` under its public name before the composition starts its first turn. Initial connection or discovery failure is always logged; it rejects activation when `failOnStartupError` is true and otherwise activates with no tools.
|
||||
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
|
||||
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
|
||||
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
|
||||
|
||||
@@ -44,6 +44,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
| `url` | http | 是 | MCP 服务器 URL |
|
||||
| `headers` | http | 否 | 额外标头(例如认证 token) |
|
||||
| `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) |
|
||||
| `failOnStartupError` | 两者 | 否 | 初始连接或工具发现失败时拒绝插件激活(默认 `false`) |
|
||||
|
||||
## 工具命名
|
||||
|
||||
@@ -56,7 +57,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
## 行为
|
||||
|
||||
- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接失败会记录日志,插件仍会激活但不注册工具。
|
||||
- 连接时:插件激活会等待 `listTools()`,并在组合开始首个轮次前通过 `ctx.tools.register()` 以公开名称注册每个工具。初始连接或发现失败始终会记录日志;`failOnStartupError` 为 true 时拒绝激活,否则插件仍会激活但不注册工具。
|
||||
- 监听 `notifications/tools/list_changed` → 重新同步;同步失败时保留上一世代的注册。
|
||||
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
|
||||
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ const stdioConfig: Config = {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
@@ -150,11 +151,30 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(ctx.tools.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the Cordis plugin loading until initial discovery publishes its tools', async () => {
|
||||
const connection: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
mockConnect.mockImplementation(async () => {
|
||||
await connection.promise
|
||||
})
|
||||
const fiber = ctx.plugin({ name: 'mcp-client-lifecycle', inject, apply }, stdioConfig)
|
||||
let activated = false
|
||||
const activation = Promise.resolve(fiber).then(() => { activated = true })
|
||||
|
||||
await vi.waitFor(() => { expect(mockConnect).toHaveBeenCalled() })
|
||||
expect(activated).toBe(false)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
|
||||
connection.resolve()
|
||||
await activation
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
|
||||
await apply(ctx, stdioConfig)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
expect(() => { void apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
|
||||
await expect(apply(ctx, stdioConfig)).rejects.toThrow(/serverName "srv" is already in use/)
|
||||
// First instance unaffected.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
@@ -204,6 +224,19 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects activation and still closes the client when startup failure is configured as fatal', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool discovery failed')
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
@@ -275,6 +308,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer x' },
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
await apply(ctx, httpConfig)
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('fixture server — controlled scenarios', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -164,10 +165,11 @@ describe('fixture server — duplicate serverName', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await apply(ctx, config)
|
||||
|
||||
expect(() => { void apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
|
||||
await expect(apply(ctx, config)).rejects.toThrow(/serverName "dup" is already in use/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
@@ -185,6 +187,7 @@ describe('fixture server — disposal', () => {
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
})
|
||||
|
||||
// Tools are registered before dispose.
|
||||
@@ -210,6 +213,7 @@ describe('server-everything — official test server', () => {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -277,6 +281,7 @@ describe('server-filesystem — real filesystem operations', () => {
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 30_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await apply(ctx, config)
|
||||
}, 60_000)
|
||||
@@ -393,6 +398,7 @@ describe('streamable-http — in-process MCP server', () => {
|
||||
url: baseUrl,
|
||||
headers: { Authorization: 'Bearer e2e-test-token' },
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
await apply(ctx, config)
|
||||
}, 30_000)
|
||||
|
||||
@@ -713,6 +713,7 @@ describe('createTransport', () => {
|
||||
env: {},
|
||||
cwd: '/tmp',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -727,6 +728,7 @@ describe('createTransport', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: {},
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -741,6 +743,7 @@ describe('createTransport', () => {
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
@@ -764,6 +767,7 @@ describe('createTransport', () => {
|
||||
env: { EXTRA: 'injected' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
// createTransport internally calls buildChildEnv; we verify by inspecting
|
||||
// the constructed StdioClientTransport. Since we can't inspect private fields
|
||||
@@ -791,6 +795,7 @@ describe('createTransport', () => {
|
||||
env: { CUSTOM: 'value' },
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
}
|
||||
const transport = createTransport(config)
|
||||
expect(transport).toBeDefined()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md
|
||||
README.md: 1d858c5adcc208768dcbe99f129b47abb1722431
|
||||
README.zh.md: c2d0e6d72baeb0efb25abd8d50fee2922d2e810b
|
||||
README.md: e10907408a98cd470ae935b327ae44322f2e54a7
|
||||
README.zh.md: b8ba7dccd929574cc5aa1b7a3d68bf4f1e2f80e4
|
||||
|
||||
@@ -69,7 +69,7 @@ Loading this package registers one effect-scoped Loader builtin. Each generated
|
||||
|
||||
The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`.
|
||||
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation; a network or child-process connection failure is logged and still activates with no tools.
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool discovery, so the first model request observes a successful initial tool generation, while a network, child-process, or discovery failure rejects the candidate repository generation instead of silently activating without its declared tools.
|
||||
|
||||
## Export shape
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
|
||||
|
||||
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络或子进程连接失败会记录日志,且插件仍会激活但不注册工具。
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具发现,因此首个模型请求会看到成功的初始工具 generation;网络、子进程或发现失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。
|
||||
|
||||
## 导出形状
|
||||
|
||||
|
||||
@@ -49,12 +49,14 @@ export type ResolvedMcpServer =
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
cwd: string
|
||||
failOnStartupError: true
|
||||
}
|
||||
| {
|
||||
transport: 'streamable-http'
|
||||
serverName: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
failOnStartupError: true
|
||||
}
|
||||
|
||||
function assertTemplate(value: string, location: string): void {
|
||||
@@ -135,6 +137,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro
|
||||
args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)),
|
||||
env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`),
|
||||
cwd,
|
||||
failOnStartupError: true,
|
||||
}
|
||||
}
|
||||
const url = expand(definition.url, environment, `mcpServers.${serverName}.url`)
|
||||
@@ -147,6 +150,7 @@ export function resolveMcpServers(document: McpDocument, environment: NodeJS.Pro
|
||||
serverName,
|
||||
url,
|
||||
headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`),
|
||||
failOnStartupError: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
serverName: 'expo',
|
||||
url: 'https://mcp.expo.dev/mcp',
|
||||
headers: {},
|
||||
failOnStartupError: true,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -43,6 +44,7 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
args: ['--endpoint', 'http://localhost:8000'],
|
||||
env: { DJ_API_URL: 'http://localhost:8000' },
|
||||
cwd: '/plugin',
|
||||
failOnStartupError: true,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -74,12 +76,14 @@ describe('repository plugin common .mcp.json support', () => {
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '/plugin',
|
||||
failOnStartupError: true,
|
||||
},
|
||||
{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'remote',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
failOnStartupError: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
||||
it('fails an MCP repository plugin load when its declared server cannot connect', async () => {
|
||||
const root = await temporaryDirectory('mcp-loader')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
||||
@@ -275,12 +275,10 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
const id = await ctx.loader.create({
|
||||
await expect(ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
})).rejects.toThrow('initial connection or tool discovery failed')
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user