diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdc3ee032d..cf48d49151 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,7 +55,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `mcp-manager/status` | `emit` | [`packages/mcp/mcp-manager/src/types.ts:44`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`emit`) | [`mcp-manager`](../packages/mcp/mcp-manager) | +| `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | [`mcp-manager`](../packages/mcp/mcp-manager) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 825c1ff2e9..d704d9f558 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -57,7 +57,7 @@ | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `mcp-manager/status` | `emit` | [`packages/mcp/mcp-manager/src/types.ts:44`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`emit`) | [`mcp-manager`](../packages/mcp/mcp-manager) | +| `mcp-manager/status` | `parallel` | [`packages/mcp/mcp-manager/src/types.ts:46`](../packages/mcp/mcp-manager/src/types.ts) | [`mcp-manager`](../packages/mcp/mcp-manager) (`parallel`) | [`mcp-manager`](../packages/mcp/mcp-manager) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 28d829f2f4..e8271f79f7 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -641,11 +641,11 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index ### `mcp-manager/*` events - + -#### `mcp-manager/status` — emit +#### `mcp-manager/status` — parallel -One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration. +One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration. Lifecycle operations do not wait for listeners. Synchronous throws and returned promise rejections are logged without skipping other listeners. ```ts cordis-catalog /** @@ -653,11 +653,13 @@ One managed server's status transitioned. Emitted for every published change whi * change while the manager is live and either the entry is configured or * a runtime record remains. Removed entries may emit cleanup failures; * observers learn configuration removals from plugin configuration. + * Lifecycle operations do not wait for listeners. Synchronous throws and + * returned promise rejections are logged without skipping other listeners. * @param name - the `servers` entry key. * @param status - the status now current for that server. - * @mode emit + * @mode parallel */ -'mcp-manager/status'(name: string, status: McpManagedServerStatus): void +'mcp-manager/status'(name: string, status: McpManagedServerStatus): void | Promise ``` Source: [`packages/mcp/mcp-manager/src/types.ts`](../../packages/mcp/mcp-manager/src/types.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 7c3194e100..5185f003c1 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -641,11 +641,11 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index ### `mcp-manager/*` events - + -#### `mcp-manager/status` — emit +#### `mcp-manager/status` — parallel -One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration. +One managed server's status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration. Lifecycle operations do not wait for listeners. Synchronous throws and returned promise rejections are logged without skipping other listeners. ```ts cordis-catalog /** @@ -653,11 +653,13 @@ One managed server's status transitioned. Emitted for every published change whi * change while the manager is live and either the entry is configured or * a runtime record remains. Removed entries may emit cleanup failures; * observers learn configuration removals from plugin configuration. + * Lifecycle operations do not wait for listeners. Synchronous throws and + * returned promise rejections are logged without skipping other listeners. * @param name - the `servers` entry key. * @param status - the status now current for that server. - * @mode emit + * @mode parallel */ -'mcp-manager/status'(name: string, status: McpManagedServerStatus): void +'mcp-manager/status'(name: string, status: McpManagedServerStatus): void | Promise ``` Source: [`packages/mcp/mcp-manager/src/types.ts`](../../packages/mcp/mcp-manager/src/types.ts) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8f55f7f993..4a58d77e29 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4135,10 +4135,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ }, { name: 'mcp-manager/status', - mode: 'emit', - signature: '\'mcp-manager/status\'(name: string, status: McpManagedServerStatus): void', + mode: 'parallel', + signature: '\'mcp-manager/status\'(name: string, status: McpManagedServerStatus): void | Promise', summary: 'One managed server\'s status transitioned.', - description: 'One managed server\'s status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration.', + description: 'One managed server\'s status transitioned. Emitted for every published change while the manager is live and either the entry is configured or a runtime record remains. Removed entries may emit cleanup failures; observers learn configuration removals from plugin configuration. Lifecycle operations do not wait for listeners. Synchronous throws and returned promise rejections are logged without skipping other listeners.', parameters: [{ name: 'name', description: 'the `servers` entry key.' }, { name: 'status', description: 'the status now current for that server.' }], }, { diff --git a/packages/mcp/mcp-manager/README.i18n.yaml b/packages/mcp/mcp-manager/README.i18n.yaml index d7eb73cd82..fcd0ef94e6 100644 --- a/packages/mcp/mcp-manager/README.i18n.yaml +++ b/packages/mcp/mcp-manager/README.i18n.yaml @@ -27,8 +27,8 @@ en: a2045deb18cc3be4 zh: f58785a60cad5aa3 /deepseek-ai-dsh-mcp-manager/use-this-package/observing-and-recovering: - en: a5f16f565453fb41 - zh: 9e7cd533e4a2bfd5 + en: e0ea732b62c36403 + zh: 9c3edc9bbe70ac97 /deepseek-ai-dsh-mcp-manager/understand-the-implementation: en: 8a74186fa5bfe79d zh: c7c952876ca2992b diff --git a/packages/mcp/mcp-manager/README.md b/packages/mcp/mcp-manager/README.md index 24bea64834..83760dde12 100644 --- a/packages/mcp/mcp-manager/README.md +++ b/packages/mcp/mcp-manager/README.md @@ -82,7 +82,7 @@ Settings writes persist through ConfigEditor with revision checks; Loader update ### Observing and recovering -`ctx.mcpServers.list()` returns each server's `name`, `configured` flag, and live `status`. Configuration fields remain available through Settings. The list includes removed entries whose cleanup is still pending or failed, with `configured: false`; a cleanup failure retains the row until the Host restarts. Cleanup status retains the first diagnostic and reports `closeConfirmed: false` if any instance or probe could not confirm closure. Published status changes emit `mcp-manager/status` while the manager is live, including cleanup failures after configuration removal. +`ctx.mcpServers.list()` returns each server's `name`, `configured` flag, and live `status`. Configuration fields remain available through Settings. The list includes removed entries whose cleanup is still pending or failed, with `configured: false`; a cleanup failure retains the row until the Host restarts. Cleanup status retains the first diagnostic and reports `closeConfirmed: false` if any instance or probe could not confirm closure. Published status changes emit `mcp-manager/status` while the manager is live, including cleanup failures after configuration removal. Lifecycle operations do not wait for listeners; synchronous throws and returned promise rejections are logged without skipping other listeners. Reconnection stays inside mcp-client. `restart(name)` replaces an instance and resets its reconnect budget, including after that budget runs out. Unconfirmed transport closure or a local cleanup error blocks replacement and further probes for that server; restart the Host to recover. `probe(name, options)` resolves templates, connects, reads server identity and tool names, and closes without registering tools or reserving a namespace. Disabled entries can be probed. The probe has its own deadline and cancellation signal, and reports connection/listing success separately from closure and cleanup results. diff --git a/packages/mcp/mcp-manager/README.zh.md b/packages/mcp/mcp-manager/README.zh.md index eb562bdc64..115604d3b1 100644 --- a/packages/mcp/mcp-manager/README.zh.md +++ b/packages/mcp/mcp-manager/README.zh.md @@ -82,7 +82,7 @@ Settings 写入经 ConfigEditor 持久化并检查 revision;Loader 更新 vola ### 观察与恢复 -`ctx.mcpServers.list()` 返回各服务器的 `name`、`configured` 标记与实时 `status`。配置字段仍通过 Settings 读取。已从配置移除、但仍在清理或清理失败的条目继续列出,`configured: false`;清理失败时该行保留到宿主重启。清理状态保留首个诊断;任一实例或探测未确认关闭时,`closeConfirmed` 保持 `false`。管理器存活期间,已发布状态的变化会发出 `mcp-manager/status`,包括配置移除后的清理失败。 +`ctx.mcpServers.list()` 返回各服务器的 `name`、`configured` 标记与实时 `status`。配置字段仍通过 Settings 读取。已从配置移除、但仍在清理或清理失败的条目继续列出,`configured: false`;清理失败时该行保留到宿主重启。清理状态保留首个诊断;任一实例或探测未确认关闭时,`closeConfirmed` 保持 `false`。管理器存活期间,已发布状态的变化会发出 `mcp-manager/status`,包括配置移除后的清理失败。生命周期操作不等待监听器完成;监听器同步抛错或返回的 Promise 拒绝都会被记录,其他监听器仍会收到通知。 重连留在 mcp-client 内。`restart(name)` 替换实例并重置重连预算,预算耗尽后同样适用。传输关闭未确认或本地清理出错时,会阻止该服务器的替换与后续探测;需要重启宿主恢复。`probe(name, options)` 解析模板、连接、读取服务器身份与工具名,再关闭,不注册工具或预留 namespace。已停用条目也可探测。探测有独立的截止时间与取消信号,并分别报告连接/列表查询成功与关闭、清理结果。 diff --git a/packages/mcp/mcp-manager/src/index.ts b/packages/mcp/mcp-manager/src/index.ts index 6190ef9e8f..05c39b0cb1 100644 --- a/packages/mcp/mcp-manager/src/index.ts +++ b/packages/mcp/mcp-manager/src/index.ts @@ -459,16 +459,10 @@ export default class McpServerManager extends Service { if (previous !== undefined && deepEqualJson(previous, status)) return this.statuses.set(name, status) if (this.stopped) return - // A consumer's throwing listener must not break the lifecycle operation - // publishing the status (docs/defensive-patterns.md: contain callback - // exceptions). Invariant companions report through their own reporter, - // not by throwing here, so containment costs them nothing. - try { - this.ctx.emit('mcp-manager/status', name, status) - } catch (error) { + void this.ctx.parallel('mcp-manager/status', name, status).catch((error: unknown) => { this.ctx.logger.error(`mcp-manager: a mcp-manager/status listener failed for "${name}"`) this.ctx.logger.error(error) - } + }) } /** diff --git a/packages/mcp/mcp-manager/src/types.ts b/packages/mcp/mcp-manager/src/types.ts index 170d486bdf..3404561c97 100644 --- a/packages/mcp/mcp-manager/src/types.ts +++ b/packages/mcp/mcp-manager/src/types.ts @@ -37,10 +37,12 @@ declare module '@deepseek-ai/cordis' { * change while the manager is live and either the entry is configured or * a runtime record remains. Removed entries may emit cleanup failures; * observers learn configuration removals from plugin configuration. + * Lifecycle operations do not wait for listeners. Synchronous throws and + * returned promise rejections are logged without skipping other listeners. * @param name - the `servers` entry key. * @param status - the status now current for that server. - * @mode emit + * @mode parallel */ - 'mcp-manager/status'(name: string, status: McpManagedServerStatus): void + 'mcp-manager/status'(name: string, status: McpManagedServerStatus): void | Promise } } diff --git a/packages/mcp/mcp-manager/tests/manager.spec.ts b/packages/mcp/mcp-manager/tests/manager.spec.ts index f5a31e8ce0..d11d776b00 100644 --- a/packages/mcp/mcp-manager/tests/manager.spec.ts +++ b/packages/mcp/mcp-manager/tests/manager.spec.ts @@ -934,19 +934,28 @@ describe('mcp-manager', () => { } }) - it('contains a throwing status listener so the lifecycle operation completes', async () => { + it.each(['throws', 'rejects'] as const)('notifies later status listeners and completes the lifecycle when a listener %s', async (failureMode) => { const { ctx, manager, settings } = await mountWorld({ settingsDoc: { mcp: { servers: { srv: stdioEntry() } } }, }) await waitForState(manager, 'srv', 'connected') - const errors: string[] = [] - ctx.logger.error = ((message: unknown) => { errors.push(String(message)) }) as typeof ctx.logger.error + const failure = new Error('listener bomb') + const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { logError.mockRestore() }) ctx.on('mcp-manager/status', () => { - throw new Error('listener bomb') + if (failureMode === 'throws') throw failure + return Promise.reject(failure) }) + const laterEvents: Array<{ name: string; status: McpManagedServerStatus }> = [] + ctx.on('mcp-manager/status', (name, status) => { laterEvents.push({ name, status }) }) await settings!.update('mcp', { servers: { srv: { enabled: false } } }) await waitForState(manager, 'srv', 'disabled') - expect(errors.some(line => line.includes('mcp-manager/status listener failed'))).toBe(true) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(laterEvents).toContainEqual({ name: 'srv', status: { state: 'disabled' } }) + await vi.waitFor(() => { + expect(logError).toHaveBeenCalledWith('mcp-manager: a mcp-manager/status listener failed for "srv"') + expect(logError).toHaveBeenCalledWith(expect.objectContaining({ errors: [failure] })) + }) }) it('unwinds every instance and its tools when the manager is disposed, then refuses operations', async () => {