@animalabs/agent-framework 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/src/agent.d.ts +10 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +33 -3
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/code-execution/index.d.ts +1 -1
  6. package/dist/src/code-execution/index.d.ts.map +1 -1
  7. package/dist/src/code-execution/index.js.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +23 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +40 -4
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  13. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  14. package/dist/src/code-execution/runtime-py.js +156 -7
  15. package/dist/src/code-execution/runtime-py.js.map +1 -1
  16. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  17. package/dist/src/code-execution/tool-definition.js +27 -2
  18. package/dist/src/code-execution/tool-definition.js.map +1 -1
  19. package/dist/src/framework.d.ts +234 -5
  20. package/dist/src/framework.d.ts.map +1 -1
  21. package/dist/src/framework.js +1818 -188
  22. package/dist/src/framework.js.map +1 -1
  23. package/dist/src/gate/event-gate.d.ts +11 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -1
  25. package/dist/src/gate/event-gate.js +66 -0
  26. package/dist/src/gate/event-gate.js.map +1 -1
  27. package/dist/src/index.d.ts +1 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  32. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  33. package/dist/src/mcpl/capability-grant.js +291 -0
  34. package/dist/src/mcpl/capability-grant.js.map +1 -0
  35. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  36. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  37. package/dist/src/mcpl/capability-mask.js +190 -0
  38. package/dist/src/mcpl/capability-mask.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +30 -2
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +216 -43
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  45. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  47. package/dist/src/mcpl/errors.d.ts +6 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -1
  49. package/dist/src/mcpl/errors.js +6 -0
  50. package/dist/src/mcpl/errors.js.map +1 -1
  51. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  53. package/dist/src/mcpl/feature-set-manager.js +45 -4
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  57. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  59. package/dist/src/mcpl/index.d.ts +1 -1
  60. package/dist/src/mcpl/index.d.ts.map +1 -1
  61. package/dist/src/mcpl/index.js.map +1 -1
  62. package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
  63. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-stream-router.js +16 -0
  65. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  66. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  67. package/dist/src/mcpl/push-handler.js +20 -2
  68. package/dist/src/mcpl/push-handler.js.map +1 -1
  69. package/dist/src/mcpl/server-connection.d.ts +93 -9
  70. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  71. package/dist/src/mcpl/server-connection.js +231 -32
  72. package/dist/src/mcpl/server-connection.js.map +1 -1
  73. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  74. package/dist/src/mcpl/server-registry.js +4 -3
  75. package/dist/src/mcpl/server-registry.js.map +1 -1
  76. package/dist/src/mcpl/tags.d.ts +24 -0
  77. package/dist/src/mcpl/tags.d.ts.map +1 -0
  78. package/dist/src/mcpl/tags.js +52 -0
  79. package/dist/src/mcpl/tags.js.map +1 -0
  80. package/dist/src/mcpl/transport.d.ts +1 -1
  81. package/dist/src/mcpl/transport.d.ts.map +1 -1
  82. package/dist/src/mcpl/transport.js +35 -7
  83. package/dist/src/mcpl/transport.js.map +1 -1
  84. package/dist/src/mcpl/types.d.ts +169 -64
  85. package/dist/src/mcpl/types.d.ts.map +1 -1
  86. package/dist/src/mcpl/types.js +16 -2
  87. package/dist/src/mcpl/types.js.map +1 -1
  88. package/dist/src/module-registry.d.ts +12 -0
  89. package/dist/src/module-registry.d.ts.map +1 -1
  90. package/dist/src/module-registry.js +26 -0
  91. package/dist/src/module-registry.js.map +1 -1
  92. package/dist/src/modules/workspace/index.d.ts +20 -0
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +48 -15
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/sync.d.ts +4 -0
  97. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/sync.js +1 -1
  99. package/dist/src/modules/workspace/sync.js.map +1 -1
  100. package/dist/src/refusal-reactions.d.ts +27 -0
  101. package/dist/src/refusal-reactions.d.ts.map +1 -0
  102. package/dist/src/refusal-reactions.js +35 -0
  103. package/dist/src/refusal-reactions.js.map +1 -0
  104. package/dist/src/tool-result-history.d.ts +10 -0
  105. package/dist/src/tool-result-history.d.ts.map +1 -1
  106. package/dist/src/tool-result-history.js +16 -5
  107. package/dist/src/tool-result-history.js.map +1 -1
  108. package/dist/src/types/agent.d.ts +35 -0
  109. package/dist/src/types/agent.d.ts.map +1 -1
  110. package/dist/src/types/framework.d.ts +26 -0
  111. package/dist/src/types/framework.d.ts.map +1 -1
  112. package/dist/src/types/module.d.ts +22 -0
  113. package/dist/src/types/module.d.ts.map +1 -1
  114. package/dist/src/types/trace.d.ts +24 -0
  115. package/dist/src/types/trace.d.ts.map +1 -1
  116. package/dist/test/busy-agent-scheduler.test.js +1 -0
  117. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  118. package/dist/test/channel-registry-routing.test.js +38 -10
  119. package/dist/test/channel-registry-routing.test.js.map +1 -1
  120. package/dist/test/channel-registry-typing.test.js +10 -1
  121. package/dist/test/channel-registry-typing.test.js.map +1 -1
  122. package/dist/test/checkpoint-attribution.test.js +168 -8
  123. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  124. package/dist/test/code-execution.test.js +327 -1
  125. package/dist/test/code-execution.test.js.map +1 -1
  126. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  127. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  128. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  129. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  130. package/dist/test/deferred-wake-integrity.test.js +320 -0
  131. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  132. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  133. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  134. package/dist/test/event-gate-selfwake.test.js +125 -0
  135. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  136. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  137. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  138. package/dist/test/machine-close-provenance.test.js +116 -0
  139. package/dist/test/machine-close-provenance.test.js.map +1 -0
  140. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  141. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  142. package/dist/test/mcpl-access-provider.test.js +20 -0
  143. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  144. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  145. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  146. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  147. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  148. package/dist/test/mcpl-capability-grant.test.js +109 -0
  149. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  150. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  151. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  152. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  153. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  154. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  155. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  156. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  157. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-list-status.test.js +78 -0
  159. package/dist/test/mcpl-list-status.test.js.map +1 -0
  160. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  161. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  163. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  164. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  165. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  166. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  167. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  168. package/dist/test/mcpl-request-timeout.test.js +1 -1
  169. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  170. package/dist/test/module-context-integration.test.d.ts +18 -0
  171. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  172. package/dist/test/module-context-integration.test.js +181 -0
  173. package/dist/test/module-context-integration.test.js.map +1 -0
  174. package/dist/test/physical-window-restart.test.d.ts +14 -0
  175. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  176. package/dist/test/physical-window-restart.test.js +222 -0
  177. package/dist/test/physical-window-restart.test.js.map +1 -0
  178. package/dist/test/present-while-acting.test.js +270 -0
  179. package/dist/test/present-while-acting.test.js.map +1 -1
  180. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  181. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  182. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  183. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  184. package/dist/test/state-scaling-e2e.test.js +7 -1
  185. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  186. package/dist/test/state-scaling.test.js +9 -1
  187. package/dist/test/state-scaling.test.js.map +1 -1
  188. package/dist/test/tool-result-spill.test.d.ts +13 -0
  189. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  190. package/dist/test/tool-result-spill.test.js +463 -0
  191. package/dist/test/tool-result-spill.test.js.map +1 -0
  192. package/dist/test/trunk-channel-routing.test.js +45 -0
  193. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  194. package/dist/test/utils-surface.test.d.ts +2 -0
  195. package/dist/test/utils-surface.test.d.ts.map +1 -0
  196. package/dist/test/utils-surface.test.js +145 -0
  197. package/dist/test/utils-surface.test.js.map +1 -0
  198. package/dist/test/workspace-read-image.test.js +71 -0
  199. package/dist/test/workspace-read-image.test.js.map +1 -1
  200. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  201. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  202. package/dist/test/workspace-restore-order.test.js +140 -0
  203. package/dist/test/workspace-restore-order.test.js.map +1 -0
  204. package/dist/tsconfig.tsbuildinfo +1 -1
  205. package/package.json +4 -4
  206. package/src/agent.ts +37 -3
  207. package/src/code-execution/index.ts +1 -0
  208. package/src/code-execution/py-runner.ts +56 -4
  209. package/src/code-execution/runtime-py.ts +156 -7
  210. package/src/code-execution/tool-definition.ts +27 -2
  211. package/src/framework.ts +2080 -203
  212. package/src/gate/event-gate.ts +72 -0
  213. package/src/index.ts +1 -0
  214. package/src/mcpl/capability-grant.ts +319 -0
  215. package/src/mcpl/capability-mask.ts +228 -0
  216. package/src/mcpl/channel-registry.ts +265 -43
  217. package/src/mcpl/checkpoint-manager.ts +28 -8
  218. package/src/mcpl/errors.ts +7 -0
  219. package/src/mcpl/feature-set-manager.ts +50 -5
  220. package/src/mcpl/hook-orchestrator.ts +64 -86
  221. package/src/mcpl/index.ts +0 -2
  222. package/src/mcpl/prose-stream-router.ts +17 -0
  223. package/src/mcpl/push-handler.ts +18 -2
  224. package/src/mcpl/server-connection.ts +262 -34
  225. package/src/mcpl/server-registry.ts +4 -3
  226. package/src/mcpl/tags.ts +50 -0
  227. package/src/mcpl/transport.ts +37 -7
  228. package/src/mcpl/types.ts +187 -78
  229. package/src/module-registry.ts +34 -0
  230. package/src/modules/workspace/index.ts +49 -15
  231. package/src/modules/workspace/sync.ts +1 -1
  232. package/src/refusal-reactions.ts +37 -0
  233. package/src/tool-result-history.ts +17 -5
  234. package/src/types/agent.ts +36 -0
  235. package/src/types/framework.ts +27 -0
  236. package/src/types/module.ts +24 -0
  237. package/src/types/trace.ts +22 -0
@@ -34,6 +34,8 @@ import type {
34
34
  import type { McplServerRegistry } from './server-registry.js';
35
35
  import type { FeatureSetManager } from './feature-set-manager.js';
36
36
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
37
+ import { expandCoreTags } from './tags.js';
38
+ import { CapabilityGrant } from './capability-grant.js';
37
39
 
38
40
  // ============================================================================
39
41
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
@@ -253,7 +255,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
253
255
  'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
254
256
  'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
255
257
  'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
256
- 'and is NOT posted. To reply instead, just write plain text (no tool call).',
258
+ 'and is NOT posted. To reply instead, just write plain text (no tool call). ' +
259
+ 'To end this turn but come back on your own shortly, set wake_in_seconds.',
257
260
  inputSchema: {
258
261
  type: 'object' as const,
259
262
  properties: {
@@ -261,6 +264,13 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
261
264
  type: 'string',
262
265
  description: 'Optional private note on why you are not replying (not sent anywhere).',
263
266
  },
267
+ wake_in_seconds: {
268
+ type: 'number',
269
+ description:
270
+ 'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
271
+ 'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
272
+ 'wake before then cancels it. Omit to stay idle until the next external wake.',
273
+ },
264
274
  },
265
275
  required: [],
266
276
  },
@@ -337,6 +347,21 @@ interface ChannelRegistryOptions {
337
347
  // ChannelRegistry
338
348
  // ============================================================================
339
349
 
350
+ /**
351
+ * Who is actually making a channel tool call, as established by the
352
+ * framework's own dispatch — model-origin calls come through
353
+ * dispatchToolCall (which knows the agent), module calls come through
354
+ * ModuleContext.callTool. Tool INPUT can claim anything; this cannot.
355
+ */
356
+ export type ChannelToolOrigin =
357
+ | { kind: 'module'; moduleName?: string }
358
+ | { kind: 'agent'; agentName: string };
359
+
360
+ /** The machine decision sources a module-origin channel_close may record.
361
+ * Closed on purpose: honest provenance means a small, auditable vocabulary,
362
+ * not arbitrary self-description. */
363
+ export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
364
+
340
365
  export class ChannelRegistry {
341
366
  private serverRegistry: McplServerRegistry;
342
367
  private featureSetManager: FeatureSetManager;
@@ -443,8 +468,19 @@ export class ChannelRegistry {
443
468
  responder?: Responder,
444
469
  ): Promise<void> {
445
470
  const registeredIds: string[] = [];
471
+ // §14.5: per-descriptor authorization with ITEMIZED results — one entry
472
+ // per submitted descriptor. Strict 0.5 servers treat a result without
473
+ // `results` (or a descriptor missing from it) as rejected, so the shape
474
+ // is interop-critical, not decorative. Rejection here is per-descriptor
475
+ // validation; the method-level channels.register gate already ran at the
476
+ // connection (§14.1).
477
+ const results: ChannelsRegisterResult['results'] = [];
446
478
 
447
479
  for (const channel of params.channels) {
480
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
481
+ results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
482
+ continue;
483
+ }
448
484
  const key = `${serverId}:${channel.id}`;
449
485
  this.channels.set(key, {
450
486
  serverId,
@@ -452,11 +488,12 @@ export class ChannelRegistry {
452
488
  open: false,
453
489
  });
454
490
  registeredIds.push(channel.id);
491
+ results.push({ id: channel.id, accepted: true });
455
492
  }
456
493
 
457
494
  // Respond before reconciliation — the server blocks on this response and
458
495
  // can't process channels/open until it arrives.
459
- const result: ChannelsRegisterResult = { registered: registeredIds };
496
+ const result: ChannelsRegisterResult = { registered: registeredIds, results };
460
497
  responder?.respond(result);
461
498
 
462
499
  // One-time migration from the retired recipe policy, then reconcile the
@@ -481,39 +518,69 @@ export class ChannelRegistry {
481
518
  async handleChanged(
482
519
  serverId: string,
483
520
  params: ChannelsChangedParams,
521
+ responder?: Responder,
484
522
  ): Promise<void> {
485
- // Process removed channels
523
+ // §14.5: dual-mode. Request form answers ITEMIZED per-descriptor
524
+ // results for added channels; Notification form filters itemwise with a
525
+ // diagnostic. Either way, added descriptors are validated exactly like
526
+ // channels/register — a changed-set cannot smuggle in what register
527
+ // would have rejected (PR #79 review blocker 5).
528
+ const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
529
+ // Process removed channels — itemized truthfully: removal of a channel
530
+ // we never had is reported, not silently accepted.
486
531
  if (params.removed) {
487
532
  for (const channelId of params.removed) {
488
533
  const key = `${serverId}:${channelId}`;
489
- this.channels.delete(key);
534
+ const existed = this.channels.delete(key);
490
535
  this.stopTyping(channelId);
536
+ addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
491
537
  }
492
538
  }
493
539
 
494
- // Process updated channels (replace descriptor, preserve open state)
540
+ // Process updated channels — validated itemwise exactly like added
541
+ // (§14.5: a changed-set cannot smuggle in what register would reject),
542
+ // and every submitted descriptor gets a verdict: a strict server reads
543
+ // absence from `results` as rejection.
495
544
  if (params.updated) {
496
545
  for (const channel of params.updated) {
546
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
547
+ addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
548
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
549
+ continue;
550
+ }
497
551
  const key = `${serverId}:${channel.id}`;
498
552
  const existing = this.channels.get(key);
499
- if (existing) {
500
- existing.descriptor = channel;
553
+ if (!existing) {
554
+ addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
555
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
556
+ continue;
501
557
  }
558
+ existing.descriptor = channel;
559
+ addedResults.push({ id: channel.id, accepted: true });
502
560
  }
503
561
  }
504
562
 
505
- // Process added channels (register + reconcile durable desired state)
563
+ // Process added channels (validate per-descriptor, register, reconcile)
564
+ const accepted: typeof params.added = [];
506
565
  if (params.added) {
507
566
  for (const channel of params.added) {
567
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
568
+ addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
569
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
570
+ continue;
571
+ }
508
572
  const key = `${serverId}:${channel.id}`;
509
573
  this.channels.set(key, {
510
574
  serverId,
511
575
  descriptor: channel,
512
576
  open: false,
513
577
  });
578
+ addedResults.push({ id: channel.id, accepted: true });
579
+ accepted.push(channel);
514
580
  }
515
- await this.reconcileChannels(serverId, params.added);
581
+ if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
516
582
  }
583
+ responder?.respond({ results: addedResults });
517
584
 
518
585
  this.emitTraceFn({
519
586
  type: 'mcpl:channels-changed',
@@ -538,13 +605,11 @@ export class ChannelRegistry {
538
605
  const results: ChannelIncomingMessageResult[] = [];
539
606
 
540
607
  for (const message of params.messages) {
541
- // Convert MCPL content blocks to membrane ContentBlocks
542
- const convertedContent: ContentBlock[] = message.content.map(convertBlock);
543
-
544
- // Track default publish channel (most recent incoming)
545
- this.defaultPublishChannel = message.channelId;
546
- this.defaultPublishMessageId = message.messageId;
547
- this.defaultPublishThreadId = message.threadId;
608
+ // §14.5 FIRST, before ANY semantic processing: admission against the
609
+ // actually-registered channel precedes tag expansion and content
610
+ // conversion — decoding an unregistered sender's payload (including
611
+ // inline base64) is allocation and parsing work done for a message
612
+ // that must be rejected (Sol, PR #79 re-review blocker 2).
548
613
 
549
614
  // Lazy-register the channel if we've never seen it. A channel can deliver
550
615
  // an incoming message before its channels/register (boot enumeration) or
@@ -557,31 +622,44 @@ export class ChannelRegistry {
557
622
  // is reachable. The inbound message carries enough to make it publishable,
558
623
  // so register it here; a later authoritative channels/register or
559
624
  // channels/changed will overwrite this descriptor with the richer one.
625
+ // §14.5: channels/incoming is validated against the ACTUALLY
626
+ // REGISTERED channel. An unknown claimed channelId is rejected
627
+ // per-message with an itemized result — never minted by its first
628
+ // message (PR #79 review blocker 5: lazy-registration let a server
629
+ // self-attest a channel identity without ever passing
630
+ // channels/register authorization). A server whose channel genuinely
631
+ // exists registers it first; discord's DM case goes through
632
+ // ensureChannelRegistered on push/event, which creates a CLOSED
633
+ // routable entry rather than an open self-attested one.
560
634
  const incomingKey = `${serverId}:${message.channelId}`;
561
635
  if (!this.channels.has(incomingKey)) {
562
- const channelLabel =
563
- typeof message.metadata?.channelName === 'string' && message.metadata.channelName
564
- ? (message.metadata.channelName as string)
565
- : message.channelId;
566
- this.channels.set(incomingKey, {
567
- serverId,
568
- descriptor: {
569
- id: message.channelId,
570
- type: serverId,
571
- label: channelLabel,
572
- direction: 'bidirectional',
573
- address: message.threadId ? { threadId: message.threadId } : undefined,
574
- metadata: { lazyRegistered: true },
575
- },
576
- open: true,
577
- });
578
636
  this.emitTraceFn({
579
- type: 'mcpl:channel-lazy-registered',
637
+ type: 'mcpl:channel-incoming-rejected',
580
638
  serverId,
581
639
  channelId: message.channelId,
582
- label: channelLabel,
640
+ reason: 'unknown channel — not registered (§14.5)',
583
641
  });
584
- } else {
642
+ results.push({
643
+ messageId: message.messageId,
644
+ accepted: false,
645
+ reason: `unknown channel "${message.channelId}" — register it first (§14.5)`,
646
+ });
647
+ continue;
648
+ }
649
+
650
+ // ACCEPTED from here down: semantic processing only for admitted
651
+ // messages. §16.3 core-tag closure, then content conversion.
652
+ if (message.tags) message.tags = expandCoreTags(message.tags);
653
+ const convertedContent: ContentBlock[] = message.content.map(convertBlock);
654
+
655
+ // Track default publish channel (most recent ACCEPTED incoming) —
656
+ // deliberately after §14.5 validation: a rejected message from an
657
+ // unregistered channel must not retarget outbound speech (the locus is
658
+ // exactly the authority a self-attested channel would be stealing).
659
+ this.defaultPublishChannel = message.channelId;
660
+ this.defaultPublishMessageId = message.messageId;
661
+ this.defaultPublishThreadId = message.threadId;
662
+ {
585
663
  // A server sending channels/incoming is authoritative evidence that
586
664
  // the transport is actually open. This repairs transient status only;
587
665
  // durable desired state still changes exclusively through lifecycle
@@ -855,8 +933,21 @@ export class ChannelRegistry {
855
933
 
856
934
  /**
857
935
  * Handle a call to one of the synthesized channel tools.
936
+ *
937
+ * `origin` is TRUSTED DISPATCH CONTEXT, supplied by the framework's own
938
+ * routing (dispatchChannelToolCall and the public executeToolCall for
939
+ * model-origin calls, the framework's private ModuleRegistry closure for
940
+ * module ctx.callTool) — never derived from tool input. Machine
941
+ * provenance on channel_close is honored only for module origin; a
942
+ * model-origin call carrying the same fields is recorded as the agent
943
+ * decision it actually is. Absent origin is treated as agent-origin (the
944
+ * untrusted-safe default).
858
945
  */
859
- async handleChannelToolCall(toolName: string, input: unknown): Promise<ToolResult> {
946
+ async handleChannelToolCall(
947
+ toolName: string,
948
+ input: unknown,
949
+ origin?: ChannelToolOrigin,
950
+ ): Promise<ToolResult> {
860
951
  switch (toolName) {
861
952
  case 'channel_list':
862
953
  return this.handleToolList();
@@ -870,7 +961,15 @@ export class ChannelRegistry {
870
961
  });
871
962
 
872
963
  case 'channel_close':
873
- return this.handleToolClose(input as { channelId: string; serverId?: string });
964
+ return this.handleToolClose(
965
+ input as {
966
+ channelId: string;
967
+ serverId?: string;
968
+ source?: string;
969
+ overrideExplicitOpen?: boolean;
970
+ },
971
+ origin,
972
+ );
874
973
 
875
974
  case 'channel_decline':
876
975
  return this.handleToolDecline(input as {
@@ -887,7 +986,7 @@ export class ChannelRegistry {
887
986
  return this.handleToolThink(input as { content?: string });
888
987
 
889
988
  case 'skip_reply':
890
- return this.handleToolSkipReply(input as { reason?: string });
989
+ return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
891
990
 
892
991
  default:
893
992
  return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
@@ -1145,6 +1244,19 @@ export class ChannelRegistry {
1145
1244
  const server = this.serverRegistry.getServer(serverId);
1146
1245
  if (!server) return;
1147
1246
 
1247
+ // §14.1: channels/open + channels/close require channels.lifecycle.
1248
+ // Skipping reconciliation for an ungranted server is the enforcement —
1249
+ // its channels simply stay in whatever state the server chose, and the
1250
+ // host never directs lifecycle it was not granted authority over.
1251
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1252
+ this.emitTraceFn({
1253
+ type: 'mcpl:channel-reconcile-skipped',
1254
+ serverId,
1255
+ reason: 'channels.lifecycle not in effective grant (§14.1)',
1256
+ });
1257
+ return;
1258
+ }
1259
+
1148
1260
  // Channels the subscription policy freshly admitted in THIS pass —
1149
1261
  // announced to the agent as one batched notice after the loop, so a
1150
1262
  // first boot on a new policy doesn't produce one notice per channel.
@@ -1219,6 +1331,10 @@ export class ChannelRegistry {
1219
1331
  * (typing timer lifecycle is still managed for when the callback is wired).
1220
1332
  */
1221
1333
  private sendTypingNotification(serverId: string, channelId: string): void {
1334
+ // §14.1: channels/typing requires channels.typing in the grant. Silent
1335
+ // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
1336
+ // tick would be noise.
1337
+ if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
1222
1338
  if (this.sendTypingFn) {
1223
1339
  this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
1224
1340
  }
@@ -1230,6 +1346,13 @@ export class ChannelRegistry {
1230
1346
  // Private: Channel Lookup
1231
1347
  // ==========================================================================
1232
1348
 
1349
+ /** Descriptors registered by one server — the §14.3 channels/list answer. */
1350
+ descriptorsForServer(serverId: string): ChannelDescriptor[] {
1351
+ return [...this.channels.values()]
1352
+ .filter((e) => e.serverId === serverId)
1353
+ .map((e) => e.descriptor);
1354
+ }
1355
+
1233
1356
  /** Server id owning a registered channel (by descriptor id), or null. */
1234
1357
  getChannelServerId(channelId: string): string | null {
1235
1358
  return this.findChannelEntry(channelId)?.serverId ?? null;
@@ -1334,6 +1457,11 @@ export class ChannelRegistry {
1334
1457
  if (!server) {
1335
1458
  throw new Error(`Server not found: ${entry.serverId}`);
1336
1459
  }
1460
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1461
+ // Desired state is already recorded — intent sticks; reconciliation
1462
+ // will retry if the grant later widens (§6.7 expansion-on-receipt).
1463
+ throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
1464
+ }
1337
1465
  const result: ChannelsOpenResult = await server.sendChannelsOpen({
1338
1466
  channelId: entry.descriptor.id,
1339
1467
  type: entry.descriptor.type,
@@ -1588,7 +1716,25 @@ export class ChannelRegistry {
1588
1716
  }
1589
1717
  }
1590
1718
 
1591
- private async handleToolClose(input: { channelId: string; serverId?: string }): Promise<ToolResult> {
1719
+ private async handleToolClose(
1720
+ input: {
1721
+ channelId: string;
1722
+ serverId?: string;
1723
+ /** Machine callers name their decision source ('subscription-gc',
1724
+ * 'housekeeping') so the durable record carries honest provenance.
1725
+ * Honored ONLY for module-origin dispatch — tool input is untrusted,
1726
+ * so a model-origin call carrying this field is still recorded as
1727
+ * 'agent-tool' (the resident may close their own channel; they may
1728
+ * not attribute the act to housekeeping). */
1729
+ source?: string;
1730
+ /** A machine close aimed at an explicitly-opened channel is refused
1731
+ * unless the caller certifies an explicit idle lease (a configured
1732
+ * per-channel budget is consent to close at that budget). Module
1733
+ * origin only, like `source`. */
1734
+ overrideExplicitOpen?: boolean;
1735
+ },
1736
+ origin?: ChannelToolOrigin,
1737
+ ): Promise<ToolResult> {
1592
1738
  const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1593
1739
  const entry = resolved.entry;
1594
1740
  if (!entry) {
@@ -1607,7 +1753,47 @@ export class ChannelRegistry {
1607
1753
  };
1608
1754
  }
1609
1755
 
1610
- this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
1756
+ // Provenance comes from trusted dispatch, not from self-description:
1757
+ // only module-origin calls may record a machine source, and only from
1758
+ // the closed vocabulary. Agent-origin (or origin-less — the safe
1759
+ // default for any legacy caller) records 'agent-tool' and has its
1760
+ // machine fields ignored entirely.
1761
+ const isModuleOrigin = origin?.kind === 'module';
1762
+ let closeSource = 'agent-tool';
1763
+ if (isModuleOrigin && input.source !== undefined) {
1764
+ if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
1765
+ return {
1766
+ success: false,
1767
+ isError: true,
1768
+ error:
1769
+ `Unknown machine close source '${input.source}'. Machine closes must use one of: ` +
1770
+ `${[...MACHINE_CLOSE_SOURCES].join(', ')}.`,
1771
+ };
1772
+ }
1773
+ closeSource = input.source;
1774
+ }
1775
+
1776
+ // Housekeeping must not override stated intent (issue #5: GC closes wore
1777
+ // the agent's badge and reset explicitly-opened doors). A machine-sourced
1778
+ // close of a channel whose current desired state is an agent/operator
1779
+ // 'open' is refused — structurally, so the caller can stand down rather
1780
+ // than retry — unless it certifies an explicit idle lease.
1781
+ if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
1782
+ const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
1783
+ if (current?.state === 'open' && current.source === 'agent-tool') {
1784
+ return {
1785
+ success: false,
1786
+ isError: false,
1787
+ error:
1788
+ `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
1789
+ `a '${closeSource}' close does not override stated intent. Machine closes ` +
1790
+ `of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
1791
+ data: { refusal: 'explicit-open', channelId: input.channelId },
1792
+ };
1793
+ }
1794
+ }
1795
+
1796
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
1611
1797
  this.stopTyping(input.channelId);
1612
1798
 
1613
1799
  const server = this.serverRegistry.getServer(entry.serverId);
@@ -1620,6 +1806,13 @@ export class ChannelRegistry {
1620
1806
  }
1621
1807
 
1622
1808
  try {
1809
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1810
+ return {
1811
+ success: false,
1812
+ error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
1813
+ isError: true,
1814
+ };
1815
+ }
1623
1816
  await server.sendChannelsClose({ channelId: input.channelId });
1624
1817
  entry.open = false;
1625
1818
  return {
@@ -1665,6 +1858,8 @@ export class ChannelRegistry {
1665
1858
  const server = this.serverRegistry.getServer(entry.serverId);
1666
1859
  if (!server) {
1667
1860
  acknowledgmentError = `Server not found: ${entry.serverId}`;
1861
+ } else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
1862
+ acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
1668
1863
  } else {
1669
1864
  try {
1670
1865
  const result = await server.sendChannelsAcknowledge({
@@ -1733,7 +1928,14 @@ export class ChannelRegistry {
1733
1928
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
1734
1929
  * overloaded use of `think` for staying silent.
1735
1930
  */
1736
- private handleToolSkipReply(input: { reason?: string }): ToolResult {
1931
+ private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: number }): ToolResult {
1932
+ // The self-wake itself is armed by the framework (which owns the
1933
+ // EventGate) BEFORE this dispatch; when the gate is absent the framework
1934
+ // strips the field so this confirmation stays truthful.
1935
+ const wakeSecs = Number(input.wake_in_seconds);
1936
+ const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
1937
+ ? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
1938
+ : undefined;
1737
1939
  return {
1738
1940
  success: true,
1739
1941
  // The note says "ended the turn" — make it TRUE. Without endTurn the
@@ -1747,7 +1949,9 @@ export class ChannelRegistry {
1747
1949
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
1748
1950
  data: {
1749
1951
  skipped: true,
1750
- note: 'Turn ended; nothing sent.',
1952
+ note: selfWake !== undefined
1953
+ ? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
1954
+ : 'Turn ended; nothing sent.',
1751
1955
  },
1752
1956
  };
1753
1957
  }
@@ -1813,7 +2017,12 @@ export class ChannelRegistry {
1813
2017
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
1814
2018
  if (matches.length !== 1) return null;
1815
2019
  const server = this.serverRegistry.getServer(matches[0]!.serverId);
1816
- if (!server?.capabilities?.channels?.streaming) return null;
2020
+ // §5.4: the GRANT gates streaming, not the raw advertisement. The old
2021
+ // `capabilities?.channels?.streaming` check was doubly wrong: undefined
2022
+ // for the boolean `channels: true` shape (masking discord-mcpl's latent
2023
+ // double-post, AUDIT-001), and pre-policy it would have sent before the
2024
+ // server was told anything was granted.
2025
+ if (!CapabilityGrant.of(server).has('channels.streaming')) return null;
1817
2026
  return server;
1818
2027
  }
1819
2028
 
@@ -1905,6 +2114,12 @@ export class ChannelRegistry {
1905
2114
  if (!server) {
1906
2115
  return fail(channelId, `server "${entry.serverId}" not found`);
1907
2116
  }
2117
+ // §14.1: channels/publish requires channels.publish in the grant. The
2118
+ // host not sending is the enforcement — a send to an ungranted server
2119
+ // would have it act on authority it was never told it has.
2120
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
2121
+ return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`);
2122
+ }
1908
2123
 
1909
2124
  const publishParams: ChannelsPublishParams = {
1910
2125
  conversationId,
@@ -1981,6 +2196,13 @@ export class ChannelRegistry {
1981
2196
  }
1982
2197
 
1983
2198
  try {
2199
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
2200
+ return {
2201
+ success: false,
2202
+ error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
2203
+ isError: true,
2204
+ };
2205
+ }
1984
2206
  const publishParams: ChannelsPublishParams = {
1985
2207
  conversationId: '', // Framework will fill this when wired
1986
2208
  channelId,
@@ -355,17 +355,37 @@ export class CheckpointManager {
355
355
  }
356
356
 
357
357
  /**
358
- * Get the first stateful feature set for a given server.
359
- * Returns null if no stateful feature sets are registered.
358
+ * Resolve a server-managed stateful feature set for a server.
359
+ *
360
+ * An explicit feature set is returned only when it is registered and
361
+ * server-managed. Without one, the fallback is safe only when exactly one
362
+ * server-managed stateful set exists. Returns null for no match or ambiguity.
360
363
  */
361
- getStatefulFeatureSet(serverId: string): string | null {
364
+ getStatefulFeatureSet(serverId: string, featureSet?: string): string | null {
362
365
  const prefix = `${serverId}:`;
363
- for (const [key] of this.trees) {
364
- if (key.startsWith(prefix)) {
365
- return key.slice(prefix.length);
366
- }
366
+
367
+ if (featureSet !== undefined) {
368
+ const tree = this.trees.get(this.key(serverId, featureSet));
369
+ return tree && !tree.hostState ? featureSet : null;
367
370
  }
368
- return null;
371
+
372
+ let match: string | null = null;
373
+ for (const [key, tree] of this.trees) {
374
+ if (!key.startsWith(prefix) || tree.hostState) continue;
375
+ if (match !== null) return null;
376
+ match = key.slice(prefix.length);
377
+ }
378
+ return match;
379
+ }
380
+
381
+ /** Whether an untagged tool cannot safely select a server-managed set. */
382
+ hasAmbiguousServerManagedFeatureSets(serverId: string): boolean {
383
+ const prefix = `${serverId}:`;
384
+ let count = 0;
385
+ for (const [key, tree] of this.trees) {
386
+ if (key.startsWith(prefix) && !tree.hostState && ++count > 1) return true;
387
+ }
388
+ return false;
369
389
  }
370
390
 
371
391
  /**
@@ -14,6 +14,13 @@ import type { JsonRpcError } from './types.js';
14
14
  /** Message used a disabled feature set. */
15
15
  export const FEATURE_SET_NOT_ENABLED = -32001;
16
16
 
17
+ /**
18
+ * Method requires a capability the host has masked for this server
19
+ * (McplServerConfig.enabledCapabilities/disabledCapabilities). Host-side
20
+ * extension code, pending a spec assignment.
21
+ */
22
+ export const CAPABILITY_DISABLED = -32002;
23
+
17
24
  /** Message used an undeclared feature set. */
18
25
  export const UNKNOWN_FEATURE_SET = -32003;
19
26
 
@@ -18,6 +18,7 @@ import {
18
18
  FEATURE_SET_NOT_ENABLED,
19
19
  UNKNOWN_FEATURE_SET,
20
20
  } from './errors.js';
21
+ import { isKnownCapabilityPath } from './capability-grant.js';
21
22
 
22
23
  // ============================================================================
23
24
  // Error Class
@@ -143,7 +144,16 @@ export class FeatureSetManager {
143
144
  initializeServer(
144
145
  serverId: string,
145
146
  capabilities: McplCapabilities,
146
- config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
147
+ config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] },
148
+ /**
149
+ * The effective capability grant (§5.4). When provided, §6.4's
150
+ * FAIL-CLOSED derivation runs: a feature set whose `uses` is absent,
151
+ * empty, or contains an unrecognized value is disabled with reason
152
+ * `invalid_uses`; one whose `uses` names an ungranted capability is
153
+ * disabled with the missing paths recorded. Derivation overrides config
154
+ * enablement — config can narrow the derivable set, never widen past it.
155
+ */
156
+ grant?: import('./capability-grant.js').CapabilityGrant,
147
157
  ): FeatureSetsUpdateParams {
148
158
  // Cross-package compat: agent-framework's McplCapabilities types
149
159
  // featureSets as Record<string, FeatureSetDeclaration>, but mcpl-core-ts
@@ -165,11 +175,16 @@ export class FeatureSetManager {
165
175
  enabled: new Set<string>(),
166
176
  };
167
177
 
168
- // Resolve which feature sets to enable
169
- const enablePatterns = config?.enabledFeatureSets ?? [];
178
+ // §5.3 as pinned (mcpl e869744): ABSENT enabledFeatureSets constrains
179
+ // nothing — every declared set is a candidate and §6.4 derivation
180
+ // governs. PRESENT is an allowlist ([] = deny-all selection). The old
181
+ // reading (unmentioned defaults disabled) reversed the pin (PR #79
182
+ // review blocker 7). FLEET BEHAVIOR CHANGE: configs that omitted
183
+ // enabledFeatureSets now enable every derivable declared set.
170
184
  const disablePatterns = config?.disabledFeatureSets ?? [];
171
-
172
- const toEnable = resolvePatterns(enablePatterns, declared);
185
+ const toEnable = config?.enabledFeatureSets === undefined
186
+ ? Object.keys(declared)
187
+ : resolvePatterns(config.enabledFeatureSets, declared);
173
188
  const toDisable = resolvePatterns(disablePatterns, declared);
174
189
 
175
190
  // Enable matching sets
@@ -182,6 +197,36 @@ export class FeatureSetManager {
182
197
  state.enabled.delete(name);
183
198
  }
184
199
 
200
+ // §6.4 fail-closed derivation against the grant. Runs AFTER config
201
+ // resolution so it can only remove: nothing a declaration or a config
202
+ // says can supply a capability the grant lacks.
203
+ if (grant) {
204
+ for (const [name, decl] of Object.entries(declared)) {
205
+ const uses = (decl as { uses?: unknown }).uses;
206
+ if (!Array.isArray(uses) || uses.length === 0) {
207
+ if (state.enabled.delete(name) || true) {
208
+ console.error(`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — uses absent or empty)`);
209
+ }
210
+ continue;
211
+ }
212
+ const unknown = uses.filter((u) => typeof u !== 'string' || !isKnownCapabilityPath(u));
213
+ if (unknown.length > 0) {
214
+ state.enabled.delete(name);
215
+ console.error(
216
+ `[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`,
217
+ );
218
+ continue;
219
+ }
220
+ const missing = (uses as string[]).filter((u) => !grant.has(u));
221
+ if (missing.length > 0) {
222
+ state.enabled.delete(name);
223
+ console.error(
224
+ `[mcpl] ${serverId}/${name} disabled: missing capabilities ${missing.join(', ')} (§6.4)`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+
185
230
  this.servers.set(serverId, state);
186
231
 
187
232
  // Build the update params to send to the server