From 913ecf5f1dbd7dfabb6ec49754882e2c8e114d6e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:13:20 +0800 Subject: [PATCH] fix(mcp-client): await Cordis startup discovery --- ...-trusted-repository-package-code.i18n.yaml | 4 +-- ...6-08-08-trusted-repository-package-code.md | 6 ++-- ...8-08-trusted-repository-package-code.zh.md | 6 ++-- .../github-repository-plugin.built.e2e.ts | 11 +++--- docs/config-catalog.md | 6 +++- packages/mcp/mcp-client/README.i18n.yaml | 4 +-- packages/mcp/mcp-client/README.md | 3 +- packages/mcp/mcp-client/README.zh.md | 3 +- packages/mcp/mcp-client/src/index.ts | 31 ++++++++++------ packages/mcp/mcp-client/tests/apply.spec.ts | 36 ++++++++++++++++++- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 8 ++++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 5 +++ .../repository-plugin/README.i18n.yaml | 4 +-- .../repository-plugin/README.md | 2 +- .../repository-plugin/README.zh.md | 2 +- .../repository-plugin/src/mcp.ts | 4 +++ .../tests/mcp-format.spec.ts | 4 +++ .../tests/repository-plugin.spec.ts | 8 ++--- 18 files changed, 108 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml index eeea186c92..74c4f0eb41 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: 4968d29a6da4180a8b016eef9176acb9bc64d8b1 -2026-08-08-trusted-repository-package-code.zh.md: 2e9f59bb1f571bb1bace9623a9e6976e0238a017 +2026-08-08-trusted-repository-package-code.md: cf72853836901af9c1c9e9f0d3d23f43997a4e97 +2026-08-08-trusted-repository-package-code.zh.md: cea47d85973a81f904b1596051ac58996459c635 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md index 4968d29a6d..cf72853836 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md @@ -18,7 +18,7 @@ The package owns its npm dependencies and build toolchain. `scripts.prepack` is The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. -`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Initial connection failure keeps the existing contained failure contract: it is logged, the client activates with no tools, and disposal still closes the transport. +`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection or discovery failure rejects the candidate generation and rollback still closes the transport. ## Trust boundary @@ -41,11 +41,11 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent - A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm. - Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import. - A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration. -- The initial MCP connection can lengthen application startup, while a contained connection failure still yields a running application with no tools from that server. +- The initial MCP connection can lengthen application startup, and a repository-declared server that is unavailable prevents that candidate generation from activating. - Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening. ## Testing -Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication while preserving contained connect failure and teardown. +Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client. The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. That repository package installs pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Cache assertions require source files to be absent from the packed installation while both built modules, their installed dependency, copied assets, and generated wrapper are present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md index 2e9f59bb1f..cea47d8597 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md @@ -18,7 +18,7 @@ 生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 -`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。初始连接失败沿用既有的收束失败契约:系统会记录日志,client 激活但不注册工具,dispose 仍会关闭 transport。 +`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接或发现失败会拒绝候选 generation,回滚仍会关闭 transport。 ## 信任边界 @@ -41,11 +41,11 @@ - TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。 - 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。 - 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。 -- 初始 MCP 连接可能延长应用启动时间;连接失败被收束后,仍会得到一个正常运行、但不含该 server 工具的应用。 +- 初始 MCP 连接可能延长应用启动时间;repository 声明的 server 不可用时,该候选 generation 无法激活。 - Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。 ## 测试 -repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,同时保留收束连接失败与清理覆盖。 +repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。 Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。该 repository 包安装固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。缓存断言要求打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts index 2e1ea4a08c..fb0bf2937a 100644 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ b/apps/cli/tests/github-repository-plugin.built.e2e.ts @@ -67,15 +67,16 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') expect(server.requests).toHaveLength(2) + const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}` const firstRequest = JSON.stringify(server.requests[0]!.body) const secondRequest = JSON.stringify(server.requests[1]!.body) - expect(firstRequest).toContain( + expect(firstRequest, runtimeDiagnostic).toContain( 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', ) - expect(firstRequest).toContain('mcp__github_repository__proof') - expect(firstRequest).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') - expect(secondRequest).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(secondRequest).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') + expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof') + expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') + expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY') + expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') const cacheRoot = join(home, 'cache', 'repository-plugins') const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 153ac72cc3..9cc95bcaf5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -988,6 +988,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). */ @@ -1006,10 +1008,12 @@ export interface StreamableHttpConfig { headers: Record /** Per-tool-call timeout in milliseconds. */ toolCallTimeoutMs: number + /** Fail plugin activation when the initial connection or tool discovery fails. */ + failOnStartupError: boolean } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:96`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 6b6d4d9270..2d47705a0a 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -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 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 6bcc195e36..c87255917a 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -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`. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 8886c16c3f..6fd6df39d7 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -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`。 diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 9798b4cfcb..348003be52 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -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 /** 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 @@ -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 { +export async function apply(ctx: Context, config: Config): Promise { // 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 { 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 { }, ) - return () => disposers + return { getDisposers: () => disposers } })().catch((error: unknown) => { ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) - return () => new Map void>() + return { getDisposers: () => new Map 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 }) + } } diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 6bb97a7032..b2444341f1 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -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 = 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) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 6a71ab4007..e1f51d20e9 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -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) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index f70f5aa6c1..b95251e7d3 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -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() diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml index d41f4681a1..5c4ad1e929 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/self-modification/repository-plugin/README.i18n.yaml @@ -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 diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md index 1d858c5adc..e10907408a 100644 --- a/packages/self-modification/repository-plugin/README.md +++ b/packages/self-modification/repository-plugin/README.md @@ -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 diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md index c2d0e6d72b..b8ba7dccd9 100644 --- a/packages/self-modification/repository-plugin/README.zh.md +++ b/packages/self-modification/repository-plugin/README.zh.md @@ -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,而不是在缺少已声明工具的情况下静默激活。 ## 导出形状 diff --git a/packages/self-modification/repository-plugin/src/mcp.ts b/packages/self-modification/repository-plugin/src/mcp.ts index bce8179121..6c1c5ccbf3 100644 --- a/packages/self-modification/repository-plugin/src/mcp.ts +++ b/packages/self-modification/repository-plugin/src/mcp.ts @@ -49,12 +49,14 @@ export type ResolvedMcpServer = args: string[] env: Record cwd: string + failOnStartupError: true } | { transport: 'streamable-http' serverName: string url: string headers: Record + 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, } }) } diff --git a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts index 5251ccdbc2..709c1a2c4b 100644 --- a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts +++ b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts @@ -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, }, ]) }) diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts index 06cacfd699..40ae773e55 100644 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts @@ -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() })