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 () => {