@animalabs/agent-framework 0.8.0 → 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 (172) 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/framework.d.ts +171 -15
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1059 -224
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +11 -0
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +66 -0
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/index.d.ts +1 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  18. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  19. package/dist/src/mcpl/capability-grant.js +291 -0
  20. package/dist/src/mcpl/capability-grant.js.map +1 -0
  21. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  22. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  23. package/dist/src/mcpl/capability-mask.js +190 -0
  24. package/dist/src/mcpl/capability-mask.js.map +1 -0
  25. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +163 -36
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  30. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  32. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  33. package/dist/src/mcpl/errors.d.ts +6 -0
  34. package/dist/src/mcpl/errors.d.ts.map +1 -1
  35. package/dist/src/mcpl/errors.js +6 -0
  36. package/dist/src/mcpl/errors.js.map +1 -1
  37. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  38. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/feature-set-manager.js +45 -4
  40. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  41. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  42. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  43. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  44. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  45. package/dist/src/mcpl/index.d.ts +1 -1
  46. package/dist/src/mcpl/index.d.ts.map +1 -1
  47. package/dist/src/mcpl/index.js.map +1 -1
  48. package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
  49. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  50. package/dist/src/mcpl/prose-stream-router.js +16 -0
  51. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  52. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  53. package/dist/src/mcpl/push-handler.js +20 -2
  54. package/dist/src/mcpl/push-handler.js.map +1 -1
  55. package/dist/src/mcpl/server-connection.d.ts +93 -9
  56. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  57. package/dist/src/mcpl/server-connection.js +231 -32
  58. package/dist/src/mcpl/server-connection.js.map +1 -1
  59. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  60. package/dist/src/mcpl/server-registry.js +4 -3
  61. package/dist/src/mcpl/server-registry.js.map +1 -1
  62. package/dist/src/mcpl/tags.d.ts +24 -0
  63. package/dist/src/mcpl/tags.d.ts.map +1 -0
  64. package/dist/src/mcpl/tags.js +52 -0
  65. package/dist/src/mcpl/tags.js.map +1 -0
  66. package/dist/src/mcpl/transport.d.ts.map +1 -1
  67. package/dist/src/mcpl/transport.js +10 -0
  68. package/dist/src/mcpl/transport.js.map +1 -1
  69. package/dist/src/mcpl/types.d.ts +159 -64
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js +16 -2
  72. package/dist/src/mcpl/types.js.map +1 -1
  73. package/dist/src/refusal-reactions.d.ts +27 -0
  74. package/dist/src/refusal-reactions.d.ts.map +1 -0
  75. package/dist/src/refusal-reactions.js +35 -0
  76. package/dist/src/refusal-reactions.js.map +1 -0
  77. package/dist/src/tool-result-history.d.ts +10 -0
  78. package/dist/src/tool-result-history.d.ts.map +1 -1
  79. package/dist/src/tool-result-history.js +16 -5
  80. package/dist/src/tool-result-history.js.map +1 -1
  81. package/dist/src/types/agent.d.ts +35 -0
  82. package/dist/src/types/agent.d.ts.map +1 -1
  83. package/dist/src/types/framework.d.ts +16 -0
  84. package/dist/src/types/framework.d.ts.map +1 -1
  85. package/dist/src/types/trace.d.ts +24 -0
  86. package/dist/src/types/trace.d.ts.map +1 -1
  87. package/dist/test/channel-registry-routing.test.js +38 -10
  88. package/dist/test/channel-registry-routing.test.js.map +1 -1
  89. package/dist/test/channel-registry-typing.test.js +10 -1
  90. package/dist/test/channel-registry-typing.test.js.map +1 -1
  91. package/dist/test/checkpoint-attribution.test.js +168 -8
  92. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  93. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  94. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  95. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  96. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  97. package/dist/test/event-gate-selfwake.test.js +125 -0
  98. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  99. package/dist/test/machine-close-provenance.test.js +6 -0
  100. package/dist/test/machine-close-provenance.test.js.map +1 -1
  101. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  102. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  103. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  104. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  105. package/dist/test/mcpl-capability-grant.test.js +109 -0
  106. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  107. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  108. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  109. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  110. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  111. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  112. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  113. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  114. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  115. package/dist/test/mcpl-list-status.test.js +78 -0
  116. package/dist/test/mcpl-list-status.test.js.map +1 -0
  117. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  118. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  119. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  120. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  121. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  122. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  123. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  124. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  125. package/dist/test/mcpl-request-timeout.test.js +1 -1
  126. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  127. package/dist/test/physical-window-restart.test.d.ts +14 -0
  128. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  129. package/dist/test/physical-window-restart.test.js +222 -0
  130. package/dist/test/physical-window-restart.test.js.map +1 -0
  131. package/dist/test/present-while-acting.test.js +199 -1
  132. package/dist/test/present-while-acting.test.js.map +1 -1
  133. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  134. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  135. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  136. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  137. package/dist/test/state-scaling-e2e.test.js +7 -1
  138. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  139. package/dist/test/state-scaling.test.js +9 -1
  140. package/dist/test/state-scaling.test.js.map +1 -1
  141. package/dist/test/tool-result-spill.test.d.ts +13 -0
  142. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  143. package/dist/test/tool-result-spill.test.js +463 -0
  144. package/dist/test/tool-result-spill.test.js.map +1 -0
  145. package/dist/test/trunk-channel-routing.test.js +45 -0
  146. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  147. package/dist/tsconfig.tsbuildinfo +1 -1
  148. package/package.json +4 -4
  149. package/src/agent.ts +37 -3
  150. package/src/framework.ts +1219 -248
  151. package/src/gate/event-gate.ts +72 -0
  152. package/src/index.ts +1 -0
  153. package/src/mcpl/capability-grant.ts +319 -0
  154. package/src/mcpl/capability-mask.ts +228 -0
  155. package/src/mcpl/channel-registry.ts +167 -39
  156. package/src/mcpl/checkpoint-manager.ts +28 -8
  157. package/src/mcpl/errors.ts +7 -0
  158. package/src/mcpl/feature-set-manager.ts +50 -5
  159. package/src/mcpl/hook-orchestrator.ts +64 -86
  160. package/src/mcpl/index.ts +0 -2
  161. package/src/mcpl/prose-stream-router.ts +17 -0
  162. package/src/mcpl/push-handler.ts +18 -2
  163. package/src/mcpl/server-connection.ts +262 -34
  164. package/src/mcpl/server-registry.ts +4 -3
  165. package/src/mcpl/tags.ts +50 -0
  166. package/src/mcpl/transport.ts +11 -0
  167. package/src/mcpl/types.ts +176 -78
  168. package/src/refusal-reactions.ts +37 -0
  169. package/src/tool-result-history.ts +17 -5
  170. package/src/types/agent.ts +36 -0
  171. package/src/types/framework.ts +17 -0
  172. 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
  },
@@ -458,8 +468,19 @@ export class ChannelRegistry {
458
468
  responder?: Responder,
459
469
  ): Promise<void> {
460
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'] = [];
461
478
 
462
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
+ }
463
484
  const key = `${serverId}:${channel.id}`;
464
485
  this.channels.set(key, {
465
486
  serverId,
@@ -467,11 +488,12 @@ export class ChannelRegistry {
467
488
  open: false,
468
489
  });
469
490
  registeredIds.push(channel.id);
491
+ results.push({ id: channel.id, accepted: true });
470
492
  }
471
493
 
472
494
  // Respond before reconciliation — the server blocks on this response and
473
495
  // can't process channels/open until it arrives.
474
- const result: ChannelsRegisterResult = { registered: registeredIds };
496
+ const result: ChannelsRegisterResult = { registered: registeredIds, results };
475
497
  responder?.respond(result);
476
498
 
477
499
  // One-time migration from the retired recipe policy, then reconcile the
@@ -496,39 +518,69 @@ export class ChannelRegistry {
496
518
  async handleChanged(
497
519
  serverId: string,
498
520
  params: ChannelsChangedParams,
521
+ responder?: Responder,
499
522
  ): Promise<void> {
500
- // 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.
501
531
  if (params.removed) {
502
532
  for (const channelId of params.removed) {
503
533
  const key = `${serverId}:${channelId}`;
504
- this.channels.delete(key);
534
+ const existed = this.channels.delete(key);
505
535
  this.stopTyping(channelId);
536
+ addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
506
537
  }
507
538
  }
508
539
 
509
- // 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.
510
544
  if (params.updated) {
511
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
+ }
512
551
  const key = `${serverId}:${channel.id}`;
513
552
  const existing = this.channels.get(key);
514
- if (existing) {
515
- 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;
516
557
  }
558
+ existing.descriptor = channel;
559
+ addedResults.push({ id: channel.id, accepted: true });
517
560
  }
518
561
  }
519
562
 
520
- // Process added channels (register + reconcile durable desired state)
563
+ // Process added channels (validate per-descriptor, register, reconcile)
564
+ const accepted: typeof params.added = [];
521
565
  if (params.added) {
522
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
+ }
523
572
  const key = `${serverId}:${channel.id}`;
524
573
  this.channels.set(key, {
525
574
  serverId,
526
575
  descriptor: channel,
527
576
  open: false,
528
577
  });
578
+ addedResults.push({ id: channel.id, accepted: true });
579
+ accepted.push(channel);
529
580
  }
530
- await this.reconcileChannels(serverId, params.added);
581
+ if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
531
582
  }
583
+ responder?.respond({ results: addedResults });
532
584
 
533
585
  this.emitTraceFn({
534
586
  type: 'mcpl:channels-changed',
@@ -553,13 +605,11 @@ export class ChannelRegistry {
553
605
  const results: ChannelIncomingMessageResult[] = [];
554
606
 
555
607
  for (const message of params.messages) {
556
- // Convert MCPL content blocks to membrane ContentBlocks
557
- const convertedContent: ContentBlock[] = message.content.map(convertBlock);
558
-
559
- // Track default publish channel (most recent incoming)
560
- this.defaultPublishChannel = message.channelId;
561
- this.defaultPublishMessageId = message.messageId;
562
- 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).
563
613
 
564
614
  // Lazy-register the channel if we've never seen it. A channel can deliver
565
615
  // an incoming message before its channels/register (boot enumeration) or
@@ -572,31 +622,44 @@ export class ChannelRegistry {
572
622
  // is reachable. The inbound message carries enough to make it publishable,
573
623
  // so register it here; a later authoritative channels/register or
574
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.
575
634
  const incomingKey = `${serverId}:${message.channelId}`;
576
635
  if (!this.channels.has(incomingKey)) {
577
- const channelLabel =
578
- typeof message.metadata?.channelName === 'string' && message.metadata.channelName
579
- ? (message.metadata.channelName as string)
580
- : message.channelId;
581
- this.channels.set(incomingKey, {
582
- serverId,
583
- descriptor: {
584
- id: message.channelId,
585
- type: serverId,
586
- label: channelLabel,
587
- direction: 'bidirectional',
588
- address: message.threadId ? { threadId: message.threadId } : undefined,
589
- metadata: { lazyRegistered: true },
590
- },
591
- open: true,
592
- });
593
636
  this.emitTraceFn({
594
- type: 'mcpl:channel-lazy-registered',
637
+ type: 'mcpl:channel-incoming-rejected',
595
638
  serverId,
596
639
  channelId: message.channelId,
597
- label: channelLabel,
640
+ reason: 'unknown channel — not registered (§14.5)',
598
641
  });
599
- } 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
+ {
600
663
  // A server sending channels/incoming is authoritative evidence that
601
664
  // the transport is actually open. This repairs transient status only;
602
665
  // durable desired state still changes exclusively through lifecycle
@@ -923,7 +986,7 @@ export class ChannelRegistry {
923
986
  return this.handleToolThink(input as { content?: string });
924
987
 
925
988
  case 'skip_reply':
926
- return this.handleToolSkipReply(input as { reason?: string });
989
+ return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
927
990
 
928
991
  default:
929
992
  return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
@@ -1181,6 +1244,19 @@ export class ChannelRegistry {
1181
1244
  const server = this.serverRegistry.getServer(serverId);
1182
1245
  if (!server) return;
1183
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
+
1184
1260
  // Channels the subscription policy freshly admitted in THIS pass —
1185
1261
  // announced to the agent as one batched notice after the loop, so a
1186
1262
  // first boot on a new policy doesn't produce one notice per channel.
@@ -1255,6 +1331,10 @@ export class ChannelRegistry {
1255
1331
  * (typing timer lifecycle is still managed for when the callback is wired).
1256
1332
  */
1257
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;
1258
1338
  if (this.sendTypingFn) {
1259
1339
  this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
1260
1340
  }
@@ -1266,6 +1346,13 @@ export class ChannelRegistry {
1266
1346
  // Private: Channel Lookup
1267
1347
  // ==========================================================================
1268
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
+
1269
1356
  /** Server id owning a registered channel (by descriptor id), or null. */
1270
1357
  getChannelServerId(channelId: string): string | null {
1271
1358
  return this.findChannelEntry(channelId)?.serverId ?? null;
@@ -1370,6 +1457,11 @@ export class ChannelRegistry {
1370
1457
  if (!server) {
1371
1458
  throw new Error(`Server not found: ${entry.serverId}`);
1372
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
+ }
1373
1465
  const result: ChannelsOpenResult = await server.sendChannelsOpen({
1374
1466
  channelId: entry.descriptor.id,
1375
1467
  type: entry.descriptor.type,
@@ -1714,6 +1806,13 @@ export class ChannelRegistry {
1714
1806
  }
1715
1807
 
1716
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
+ }
1717
1816
  await server.sendChannelsClose({ channelId: input.channelId });
1718
1817
  entry.open = false;
1719
1818
  return {
@@ -1759,6 +1858,8 @@ export class ChannelRegistry {
1759
1858
  const server = this.serverRegistry.getServer(entry.serverId);
1760
1859
  if (!server) {
1761
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)`;
1762
1863
  } else {
1763
1864
  try {
1764
1865
  const result = await server.sendChannelsAcknowledge({
@@ -1827,7 +1928,14 @@ export class ChannelRegistry {
1827
1928
  * tool, so any trailing prose this turn is NOT posted. Replaces the old
1828
1929
  * overloaded use of `think` for staying silent.
1829
1930
  */
1830
- 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;
1831
1939
  return {
1832
1940
  success: true,
1833
1941
  // The note says "ended the turn" — make it TRUE. Without endTurn the
@@ -1841,7 +1949,9 @@ export class ChannelRegistry {
1841
1949
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
1842
1950
  data: {
1843
1951
  skipped: true,
1844
- 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.',
1845
1955
  },
1846
1956
  };
1847
1957
  }
@@ -1907,7 +2017,12 @@ export class ChannelRegistry {
1907
2017
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
1908
2018
  if (matches.length !== 1) return null;
1909
2019
  const server = this.serverRegistry.getServer(matches[0]!.serverId);
1910
- 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;
1911
2026
  return server;
1912
2027
  }
1913
2028
 
@@ -1999,6 +2114,12 @@ export class ChannelRegistry {
1999
2114
  if (!server) {
2000
2115
  return fail(channelId, `server "${entry.serverId}" not found`);
2001
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
+ }
2002
2123
 
2003
2124
  const publishParams: ChannelsPublishParams = {
2004
2125
  conversationId,
@@ -2075,6 +2196,13 @@ export class ChannelRegistry {
2075
2196
  }
2076
2197
 
2077
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
+ }
2078
2206
  const publishParams: ChannelsPublishParams = {
2079
2207
  conversationId: '', // Framework will fill this when wired
2080
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