@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
@@ -366,6 +366,13 @@ export class EventGate {
366
366
  private sleepTimer: ReturnType<typeof setTimeout> | null = null;
367
367
  private sleepAgent: string | undefined;
368
368
 
369
+ // Self-wake timers per agent (skip_reply's wake_in_seconds). Unlike sleep,
370
+ // a self-wake suppresses NOTHING — it means "if nothing else wakes me by
371
+ // then, wake me". Any turn start for the agent cancels its pending
372
+ // self-wake (an external wake supersedes it; the timer would otherwise
373
+ // land as a redundant empty wake right after the turn).
374
+ private selfWakeTimers = new Map<string, ReturnType<typeof setTimeout>>();
375
+
369
376
  // Privileged users who may wake the agent through sleep. Hot-reloaded from
370
377
  // privilegedUsersPath on change.
371
378
  private privilegedUsersPath: string | undefined;
@@ -1135,6 +1142,14 @@ export class EventGate {
1135
1142
 
1136
1143
  onInferenceStarted(agentName: string): void {
1137
1144
  this.inferring.add(agentName);
1145
+ // A turn is starting — whatever caused it supersedes any pending
1146
+ // self-wake. (Arming happens MID-turn via skip_reply, i.e. after this
1147
+ // ran for the arming turn, so a fresh arm always survives its own turn.)
1148
+ const selfWake = this.selfWakeTimers.get(agentName);
1149
+ if (selfWake) {
1150
+ clearTimeout(selfWake);
1151
+ this.selfWakeTimers.delete(agentName);
1152
+ }
1138
1153
  }
1139
1154
 
1140
1155
  onInferenceEnded(agentName: string): void {
@@ -1206,6 +1221,59 @@ export class EventGate {
1206
1221
  return { until: this.sleepUntil };
1207
1222
  }
1208
1223
 
1224
+ // =========================================================================
1225
+ // Self-wake (skip_reply wake_in_seconds)
1226
+ // =========================================================================
1227
+
1228
+ /**
1229
+ * Arm a one-shot self-wake for `agentName` in `seconds` (clamped to
1230
+ * [1, 3600]). Fires a normal inference request when it elapses — no
1231
+ * suppression window, unlike sleep. Re-arming replaces the pending timer.
1232
+ * Cancelled by the agent's next turn start (see onInferenceStarted):
1233
+ * the semantics are "if nothing else wakes me by then, wake me".
1234
+ */
1235
+ armSelfWake(agentName: string, seconds: number, source = 'skip_reply'): { inMs: number } {
1236
+ const ms = Math.max(1_000, Math.min(3_600_000, Math.floor(seconds * 1000)));
1237
+ const prev = this.selfWakeTimers.get(agentName);
1238
+ if (prev) clearTimeout(prev);
1239
+ const timer = setTimeout(() => {
1240
+ this.selfWakeTimers.delete(agentName);
1241
+ this.emitTrace({
1242
+ type: 'gate:decision',
1243
+ eventType: 'selfwake:fired',
1244
+ matchedPolicy: 'self-wake',
1245
+ trigger: true,
1246
+ behavior: 'always',
1247
+ timestamp: this.now(),
1248
+ });
1249
+ // Compact in-window notice: without it the wake turn contains nothing
1250
+ // new, so the agent can't tell WHAT woke it (its own timer vs. a
1251
+ // heartbeat) or WHEN. One line, timestamped to the second. Added
1252
+ // before the inference request so it rides the wake turn (addMessage's
1253
+ // turn-alive guard defers it to that turn's start if needed).
1254
+ const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
1255
+ this.addMessageFn('user', [{
1256
+ type: 'text',
1257
+ text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
1258
+ }], { source: 'gate:self-wake' });
1259
+ this.requestInferenceFn(
1260
+ agentName,
1261
+ `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`,
1262
+ 'self-wake',
1263
+ );
1264
+ }, ms);
1265
+ this.selfWakeTimers.set(agentName, timer);
1266
+ this.emitTrace({
1267
+ type: 'gate:decision',
1268
+ eventType: 'selfwake:set',
1269
+ matchedPolicy: 'self-wake',
1270
+ trigger: false,
1271
+ behavior: 'skip',
1272
+ timestamp: this.now(),
1273
+ });
1274
+ return { inMs: ms };
1275
+ }
1276
+
1209
1277
  /** End sleep immediately. Returns true if the agent was asleep. */
1210
1278
  clearSleep(): boolean {
1211
1279
  const wasAsleep = this.sleepUntil > this.now();
@@ -1387,6 +1455,10 @@ export class EventGate {
1387
1455
  clearTimeout(state.timer);
1388
1456
  }
1389
1457
  this.debounceTimers.clear();
1458
+ for (const timer of this.selfWakeTimers.values()) {
1459
+ clearTimeout(timer);
1460
+ }
1461
+ this.selfWakeTimers.clear();
1390
1462
  this.rateLimitBuckets.clear();
1391
1463
  this.passiveSampleCounters.clear();
1392
1464
  this.rateLimitDenied.clear();
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export type { StartStreamResult } from './agent.js';
8
8
  export { ProcessQueueImpl } from './queue.js';
9
9
  export { ModuleRegistry } from './module-registry.js';
10
10
  export { formatZonedDateTime, formatZonedTime, isValidTimeZone, resolveTimeZone } from './timezone.js';
11
+ export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
11
12
 
12
13
  // Built-in modules
13
14
  export * from './modules/index.js';
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Capability grants — the MCPL 0.5 security boundary (SPEC §5.4).
3
+ *
4
+ * Three layers, kept deliberately distinct:
5
+ *
6
+ * 1. **Advertisement walk** — what the server *can* do, read from its
7
+ * initialize response by a generic recursive walk over the §6.2
8
+ * vocabulary. Boolean `true` at any level is shorthand for every leaf
9
+ * beneath it (§5.1). An unrecognized member name cannot mint a
10
+ * capability. Advertisement is an input, never an authorization.
11
+ *
12
+ * 2. **Config mask** — host policy from `enabledCapabilities` /
13
+ * `disabledCapabilities` (folded in from PR #75, Meganeuridae's
14
+ * capability-scoping work, semantics unchanged). This is the DENY
15
+ * direction, so a pattern matching a parent masks the whole subtree —
16
+ * deny-safe by construction.
17
+ *
18
+ * 3. **The grant** — the intersection, expressed as an explicit allowlist
19
+ * of full capability paths. `effectiveCapabilities` is the sole
20
+ * normative allowlist; **absence is denial and there is no unspecified
21
+ * state**. Matching here is NOT the mask's subtree matching: `*` matches
22
+ * exactly one segment, segment counts must agree, and a bare parent
23
+ * grants nothing beneath it (§5.4 as pinned 2026-08-02 — the suffix
24
+ * reading was implemented once and adjudicated out; see mcpl e869744).
25
+ *
26
+ * §13.4: `contextHooks.beforeInference.inject.system` is DENIED BY DEFAULT.
27
+ * A server that needs it must be granted it explicitly via
28
+ * `enabledCapabilities`. This is the one place the default is not
29
+ * "everything advertised survives the mask", and it is loud when it fires.
30
+ */
31
+
32
+ import type { McplCapabilities, McplServerConfig } from './types.js';
33
+
34
+ // ============================================================================
35
+ // §6.2 vocabulary
36
+ // ============================================================================
37
+
38
+ /**
39
+ * The closed capability-path vocabulary (SPEC §6.2). A tree, not a flat
40
+ * list, because the advertisement walk and boolean-shorthand expansion are
41
+ * defined over the tree. Leaves are `null`; interior nodes that are
42
+ * themselves grantable paths (e.g. `inferenceRequest`) appear as nodes whose
43
+ * path is also emitted when advertised.
44
+ */
45
+ const VOCABULARY: CapNode = {
46
+ pushEvents: null,
47
+ tools: null,
48
+ modelInfo: null,
49
+ inferenceRequest: { streaming: null },
50
+ inferenceLifecycle: null,
51
+ contextHooks: {
52
+ beforeInference: {
53
+ observe: null,
54
+ inject: { system: null, beforeUser: null, afterUser: null },
55
+ },
56
+ },
57
+ channels: {
58
+ register: null,
59
+ lifecycle: null,
60
+ publish: null,
61
+ incoming: null,
62
+ streaming: null,
63
+ acknowledge: null,
64
+ typing: null,
65
+ },
66
+ };
67
+
68
+ interface CapNodeMap {
69
+ [key: string]: CapNode;
70
+ }
71
+ type CapNode = CapNodeMap | null;
72
+
73
+ /** Interior nodes that are grantable paths in their own right (§6.2 lists
74
+ * `inferenceRequest` alongside `inferenceRequest.streaming`). Namespaces
75
+ * (`contextHooks`, `channels`, `inject`) are not: they hold no authority. */
76
+ const SELF_GRANTABLE_INTERIOR = new Set(['inferenceRequest']);
77
+
78
+ /** Every full path in the vocabulary (leaves plus self-grantable interiors). */
79
+ export const ALL_CAPABILITY_PATHS: readonly string[] = (() => {
80
+ const out: string[] = [];
81
+ const walk = (node: CapNodeMap, prefix: string): void => {
82
+ for (const [key, child] of Object.entries(node)) {
83
+ const path = prefix ? `${prefix}.${key}` : key;
84
+ if (child === null) {
85
+ out.push(path);
86
+ } else {
87
+ if (SELF_GRANTABLE_INTERIOR.has(path)) out.push(path);
88
+ walk(child, path);
89
+ }
90
+ }
91
+ };
92
+ walk(VOCABULARY, '');
93
+ return out;
94
+ })();
95
+
96
+ const KNOWN_PATHS = new Set(ALL_CAPABILITY_PATHS);
97
+
98
+ /** Is this exact string a §6.2 capability path? (Used by §6.4 `uses`
99
+ * validation — `uses` MUST contain only these values.) */
100
+ export function isKnownCapabilityPath(path: string): boolean {
101
+ return KNOWN_PATHS.has(path);
102
+ }
103
+
104
+ // ============================================================================
105
+ // Advertisement walk (§5.1)
106
+ // ============================================================================
107
+
108
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
109
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
110
+ }
111
+
112
+ function collectSubtree(node: CapNode, path: string, out: Set<string>): void {
113
+ if (node === null) {
114
+ out.add(path);
115
+ return;
116
+ }
117
+ if (SELF_GRANTABLE_INTERIOR.has(path)) out.add(path);
118
+ for (const [key, child] of Object.entries(node)) {
119
+ collectSubtree(child, path ? `${path}.${key}` : key, out);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The set of full capability paths a capabilities object advertises.
125
+ * Generic recursive walk; `true` at any level advertises every leaf beneath
126
+ * it; `false`/absent advertises nothing; unrecognized member names are
127
+ * ignored — an unknown name cannot mint a capability.
128
+ *
129
+ * `tools` is deliberately NOT sourced here. §5.1 sources it from the OUTER
130
+ * standard MCP `capabilities.tools` only — the experimental manifest cannot
131
+ * mint standard MCP tool capability (review blocker 2 on PR #79). A nested
132
+ * `experimental.mcpl.tools` is treated as an unknown member. Callers join
133
+ * the outer signal via computeGrant's mcpToolsAdvertised.
134
+ */
135
+ export function advertisedPaths(capabilities: McplCapabilities | null): Set<string> {
136
+ const out = new Set<string>();
137
+ if (!capabilities) return out;
138
+ const walk = (vocab: CapNode, value: unknown, prefix: string): void => {
139
+ if (vocab === null || !isPlainObject(value)) return;
140
+ for (const [key, child] of Object.entries(vocab)) {
141
+ const path = prefix ? `${prefix}.${key}` : key;
142
+ if (path === 'tools') continue; // outer MCP caps only — see doc comment
143
+ const v = value[key];
144
+ if (v === true) {
145
+ collectSubtree(child, path, out);
146
+ } else if (isPlainObject(v)) {
147
+ if (child !== null) {
148
+ if (SELF_GRANTABLE_INTERIOR.has(path)) out.add(path);
149
+ walk(child, v, path);
150
+ }
151
+ }
152
+ // false / absent / non-object-non-true: advertises nothing.
153
+ }
154
+ };
155
+ walk(VOCABULARY, capabilities as unknown as Record<string, unknown>, '');
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * Expand §5.1 boolean shorthand into the explicit leaf tree, IN PLACE of the
161
+ * boolean, for every vocabulary node — so the config mask's generic child
162
+ * walk can address sub-leaves. Without this, `channels: true` is an atomic
163
+ * top-level leaf to the mask: `disabledCapabilities:['channels.streaming']`
164
+ * silently no-ops and `enabledCapabilities:['channels.publish']` drops the
165
+ * whole tree (review blocker 1 — live release geometry, since discord
166
+ * truthfully keeps `channels: true`).
167
+ *
168
+ * Unrecognized members and non-capability members (`version`, `revision`,
169
+ * `featureSets`) pass through untouched. The wire keeps its shorthand; this
170
+ * expanded form is the host's POLICY representation.
171
+ */
172
+ export function expandAdvertisementShorthand(capabilities: McplCapabilities | null): McplCapabilities | null {
173
+ if (!capabilities) return capabilities;
174
+ const expandNode = (node: CapNode): unknown => {
175
+ if (node === null) return true;
176
+ const out: Record<string, unknown> = {};
177
+ for (const [key, child] of Object.entries(node)) out[key] = expandNode(child);
178
+ return out;
179
+ };
180
+ const walk = (vocab: CapNodeMap, value: Record<string, unknown>): Record<string, unknown> => {
181
+ const out: Record<string, unknown> = { ...value };
182
+ for (const [key, child] of Object.entries(vocab)) {
183
+ if (key === 'tools') continue; // never expanded from experimental
184
+ const v = value[key];
185
+ if (child === null) continue; // leaf: boolean stays a boolean
186
+ if (v === true) {
187
+ out[key] = expandNode(child);
188
+ } else if (isPlainObject(v)) {
189
+ out[key] = walk(child, v);
190
+ }
191
+ }
192
+ return out;
193
+ };
194
+ return walk(VOCABULARY as CapNodeMap, capabilities as unknown as Record<string, unknown>) as unknown as McplCapabilities;
195
+ }
196
+
197
+ // ============================================================================
198
+ // Grant matching (§5.4) — one-segment wildcards, equal counts
199
+ // ============================================================================
200
+
201
+ /**
202
+ * `*` matches exactly one dot-segment; literal segments match exactly;
203
+ * segment counts MUST agree. A bare parent does not match its subtree and a
204
+ * trailing `*` is not a suffix wildcard (SPEC §5.4, pinned 2026-08-02).
205
+ */
206
+ export function capabilityPatternMatches(pattern: string, path: string): boolean {
207
+ const p = pattern.split('.');
208
+ const n = path.split('.');
209
+ if (p.length !== n.length) return false;
210
+ for (let i = 0; i < p.length; i++) {
211
+ if (p[i] !== '*' && p[i] !== n[i]) return false;
212
+ }
213
+ return true;
214
+ }
215
+
216
+ // ============================================================================
217
+ // The grant
218
+ // ============================================================================
219
+
220
+ /** §13.4: denied unless explicitly enabled by host config. */
221
+ const DENY_BY_DEFAULT: readonly string[] = ['contextHooks.beforeInference.inject.system'];
222
+
223
+ export class CapabilityGrant {
224
+ /** Exact granted paths (already expanded — no patterns stored). */
225
+ private readonly granted: ReadonlySet<string>;
226
+ /** Advertised-but-denied paths, for the §5.3 diagnostic field only. */
227
+ readonly deniedPaths: readonly string[];
228
+
229
+ constructor(granted: Set<string>, deniedPaths: string[]) {
230
+ this.granted = granted;
231
+ this.deniedPaths = deniedPaths;
232
+ }
233
+
234
+ /** Sole authorization question. Absence is denial (§5.4). */
235
+ has(path: string): boolean {
236
+ return this.granted.has(path);
237
+ }
238
+
239
+ /** Sorted list for `effectiveCapabilities` (§5.3). */
240
+ effectiveList(): string[] {
241
+ return [...this.granted].sort();
242
+ }
243
+
244
+ /** True when nothing at all is granted. */
245
+ isEmpty(): boolean {
246
+ return this.granted.size === 0;
247
+ }
248
+
249
+ static empty(): CapabilityGrant {
250
+ return new CapabilityGrant(new Set(), []);
251
+ }
252
+
253
+ /**
254
+ * The grant carried by a connection-like object, failing CLOSED when the
255
+ * object has none. Registry consumers receive whatever the registry holds
256
+ * — including test stubs and objects created before the field existed —
257
+ * and a missing grant must mean "nothing granted" (§5.4 absence is
258
+ * denial), never a TypeError that takes the gate down open-ended.
259
+ */
260
+ static of(conn: { grant?: CapabilityGrant } | null | undefined): CapabilityGrant {
261
+ return conn?.grant ?? CapabilityGrant.empty();
262
+ }
263
+
264
+ /**
265
+ * A copy of this grant with the named paths removed — the reduction
266
+ * primitive. §6.7's ordering contract lives at the call site: install the
267
+ * narrowed grant (establishGrant) BEFORE sending the reducing
268
+ * featureSets/update Request; security cannot wait on consent.
269
+ */
270
+ without(...paths: string[]): CapabilityGrant {
271
+ const granted = new Set(this.granted);
272
+ const denied = new Set(this.deniedPaths);
273
+ for (const p of paths) {
274
+ if (granted.delete(p)) denied.add(p);
275
+ }
276
+ return new CapabilityGrant(granted, [...denied].sort());
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Compute the effective grant for a connection: the server's advertisement
282
+ * (post config-mask — the mask runs first at handshake, so `capabilities`
283
+ * here is already the masked object) intersected with the deny-by-default
284
+ * floor. `enabledCapabilities` config patterns can re-grant a
285
+ * deny-by-default path — that is the explicit decision §13.4 asks for.
286
+ */
287
+ export function computeGrant(
288
+ capabilities: McplCapabilities | null,
289
+ config: Pick<McplServerConfig, 'enabledCapabilities'>,
290
+ opts?: {
291
+ /** §5.1: `tools` is sourced from the OUTER standard MCP
292
+ * `capabilities.tools` — pass its presence here. The experimental
293
+ * manifest cannot mint it. Without this signal, every
294
+ * `uses: ['tools']` feature set fails §6.4 derivation. */
295
+ mcpToolsAdvertised?: boolean;
296
+ },
297
+ ): CapabilityGrant {
298
+ const advertised = advertisedPaths(capabilities);
299
+ if (opts?.mcpToolsAdvertised) advertised.add('tools');
300
+ const granted = new Set<string>();
301
+ const denied: string[] = [];
302
+
303
+ const explicitlyEnabled = (path: string): boolean =>
304
+ !!config.enabledCapabilities?.some((pat) => capabilityPatternMatches(pat, path));
305
+
306
+ for (const path of advertised) {
307
+ if (DENY_BY_DEFAULT.includes(path) && !explicitlyEnabled(path)) {
308
+ denied.push(path);
309
+ console.error(
310
+ `[mcpl] "${path}" advertised but DENIED BY DEFAULT (SPEC §13.4) — ` +
311
+ `grant it explicitly via enabledCapabilities if intended`,
312
+ );
313
+ continue;
314
+ }
315
+ granted.add(path);
316
+ }
317
+
318
+ return new CapabilityGrant(granted, denied.sort());
319
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Host-side capability scoping for MCPL servers.
3
+ *
4
+ * A server names its own capabilities in its initialize response, and
5
+ * everything downstream — hook fan-out, channel streaming, push handling —
6
+ * keys off that self-advertisement. `enabledCapabilities` /
7
+ * `disabledCapabilities` in McplServerConfig let the host intersect the
8
+ * advertisement with its own policy at handshake time, so a capability the
9
+ * config masks behaves exactly as if the server had never advertised it.
10
+ *
11
+ * The mask is applied once, where the negotiated capabilities are stored
12
+ * (McplServerConnection.handshake), rather than re-checked at each consumer:
13
+ * hook orchestration, streaming observation, and capability queries all read
14
+ * the already-masked object. Inbound server-initiated methods gated by a
15
+ * masked capability are additionally rejected at the connection
16
+ * (see METHOD_TO_REQUIRED_CAPABILITY in server-connection.ts).
17
+ *
18
+ * Matching is a generic recursive walk per SPEC 0.5 §5.4: every path at
19
+ * every depth is addressable (`contextHooks.beforeInference.inject.system`
20
+ * works the day the host stores that shape), and a pattern matching a parent
21
+ * masks the whole subtree. No hardcoded set of nestable keys.
22
+ */
23
+
24
+ import type { McplCapabilities, McplServerConfig } from './types.js';
25
+
26
+ /** Result of masking: the surviving capabilities plus what was dropped. */
27
+ export interface MaskedCapabilities {
28
+ capabilities: McplCapabilities | null;
29
+ /**
30
+ * Dotted paths of advertised capabilities removed by the mask (e.g.
31
+ * `contextHooks.afterInference`). When an entire multi-flag capability is
32
+ * removed (`channels` advertised as a boolean, denied as a parent, or
33
+ * every advertised flag masked), the bare parent path (`channels`) is
34
+ * included so inbound enforcement can key on it.
35
+ */
36
+ dropped: string[];
37
+ }
38
+
39
+ /**
40
+ * Segment-wise wildcard match, same rules as feature-set patterns:
41
+ * `*` matches exactly one dot-segment, literal segments match exactly,
42
+ * segment counts must agree.
43
+ */
44
+ function wildcardMatch(pattern: string, name: string): boolean {
45
+ const patternParts = pattern.split('.');
46
+ const nameParts = name.split('.');
47
+
48
+ if (patternParts.length !== nameParts.length) {
49
+ return false;
50
+ }
51
+
52
+ for (let i = 0; i < patternParts.length; i++) {
53
+ const p = patternParts[i];
54
+ if (p === '*') {
55
+ continue;
56
+ }
57
+ if (p !== nameParts[i]) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Whether a pattern matches a capability path or any ancestor prefix of it —
67
+ * `contextHooks` (or `*`) covers `contextHooks.beforeInference.inject.system`
68
+ * at any depth, so masking a parent masks everything beneath it.
69
+ */
70
+ function patternMatchesPath(pattern: string, path: string): boolean {
71
+ const parts = path.split('.');
72
+ for (let depth = parts.length; depth >= 1; depth--) {
73
+ if (wildcardMatch(pattern, parts.slice(0, depth).join('.'))) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function anyMatches(patterns: string[], path: string): boolean {
81
+ return patterns.some((pattern) => patternMatchesPath(pattern, path));
82
+ }
83
+
84
+ /** Keys that are negotiation metadata, not grantable capabilities. */
85
+ const UNMASKABLE_KEYS = new Set(['version', 'featureSets']);
86
+
87
+ /**
88
+ * Interior object paths whose object value is itself the capability, with
89
+ * children as refinements — dropping a refinement leaves the capability
90
+ * granted (`inferenceRequest` minus `.streaming` is still inferenceRequest).
91
+ * Every other interior object is a pure namespace (`contextHooks`,
92
+ * `channels`, a future `inject`): it holds no authority of its own and is
93
+ * pruned when all its advertised children are masked.
94
+ *
95
+ * This is a retention-shape distinction only — matching and addressability
96
+ * are fully generic and depth-unbounded either way.
97
+ */
98
+ const OBJECT_CAPABILITY_PATHS = new Set(['inferenceRequest', 'contextHooks.afterInference']);
99
+
100
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
101
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
102
+ }
103
+
104
+ /**
105
+ * Intersect a server's advertised MCPL capabilities with the host config's
106
+ * `enabledCapabilities` / `disabledCapabilities`.
107
+ *
108
+ * Semantics mirror enabledTools/disabledTools: if `enabledCapabilities` is
109
+ * set, only advertised capabilities matching at least one pattern survive;
110
+ * `disabledCapabilities` removes matches and wins on conflict. Patterns are
111
+ * dotted paths with `*` matching one segment, and a pattern that matches a
112
+ * parent (`contextHooks`) covers every path beneath it. `version` and
113
+ * `featureSets` are never masked here — feature sets have their own
114
+ * enable/disable knobs.
115
+ *
116
+ * With neither list set, the advertisement passes through untouched.
117
+ */
118
+ export function maskNegotiatedCapabilities(
119
+ capabilities: McplCapabilities | null,
120
+ config: Pick<McplServerConfig, 'enabledCapabilities' | 'disabledCapabilities'>,
121
+ ): MaskedCapabilities {
122
+ const enabled = config.enabledCapabilities;
123
+ const disabled = config.disabledCapabilities;
124
+
125
+ if (capabilities === null || (!enabled && !disabled)) {
126
+ return { capabilities, dropped: [] };
127
+ }
128
+
129
+ const denied = (path: string): boolean => !!disabled && anyMatches(disabled, path);
130
+ const allowed = (path: string): boolean => !enabled || anyMatches(enabled, path);
131
+ const keep = (path: string): boolean => !denied(path) && allowed(path);
132
+
133
+ const dropped: string[] = [];
134
+
135
+ /**
136
+ * Mask one interior object node. Returns the surviving object, or
137
+ * undefined when the node is pruned entirely. Falsy children are carried
138
+ * through untouched (they advertise nothing, so there is nothing to mask).
139
+ */
140
+ const maskNode = (node: Record<string, unknown>, path: string): Record<string, unknown> | undefined => {
141
+ if (denied(path)) {
142
+ dropped.push(path);
143
+ return undefined;
144
+ }
145
+
146
+ const survivors: Record<string, unknown> = {};
147
+ let advertised = 0;
148
+ let survived = 0;
149
+
150
+ for (const [key, value] of Object.entries(node)) {
151
+ if (value === undefined) continue;
152
+ if (!value) {
153
+ // false / 0 / '' advertise nothing — preserve for fidelity.
154
+ survivors[key] = value;
155
+ continue;
156
+ }
157
+ advertised++;
158
+ const childPath = path ? `${path}.${key}` : key;
159
+
160
+ if (isPlainObject(value)) {
161
+ const masked = maskNode(value, childPath);
162
+ if (masked !== undefined) {
163
+ survivors[key] = masked;
164
+ survived++;
165
+ }
166
+ continue;
167
+ }
168
+
169
+ // Truthy leaf.
170
+ if (keep(childPath)) {
171
+ survivors[key] = value;
172
+ survived++;
173
+ } else {
174
+ dropped.push(childPath);
175
+ }
176
+ }
177
+
178
+ if (advertised === 0) {
179
+ // No truthy children: the object itself is the advert (e.g.
180
+ // `inferenceRequest: {}`), so its own path decides.
181
+ if (keep(path)) return survivors;
182
+ dropped.push(path);
183
+ return undefined;
184
+ }
185
+ if (survived > 0) {
186
+ return survivors;
187
+ }
188
+
189
+ // Every advertised child was masked. An object-capability keeps its
190
+ // (still-granted) hull; a namespace holds no authority of its own and
191
+ // is pruned, recording the bare path for inbound enforcement.
192
+ if (OBJECT_CAPABILITY_PATHS.has(path) && keep(path)) {
193
+ return survivors;
194
+ }
195
+ dropped.push(path);
196
+ return undefined;
197
+ };
198
+
199
+ const masked: Record<string, unknown> = {};
200
+ const source = capabilities as unknown as Record<string, unknown>;
201
+
202
+ for (const [key, value] of Object.entries(source)) {
203
+ if (value === undefined) continue;
204
+
205
+ if (UNMASKABLE_KEYS.has(key) || !value) {
206
+ masked[key] = value;
207
+ continue;
208
+ }
209
+
210
+ if (isPlainObject(value)) {
211
+ const node = maskNode(value, key);
212
+ if (node !== undefined) {
213
+ masked[key] = node;
214
+ }
215
+ continue;
216
+ }
217
+
218
+ // Truthy leaf at the top level (booleans, or `channels: true` from
219
+ // servers that advertise the boolean form).
220
+ if (keep(key)) {
221
+ masked[key] = value;
222
+ } else {
223
+ dropped.push(key);
224
+ }
225
+ }
226
+
227
+ return { capabilities: masked as unknown as McplCapabilities, dropped };
228
+ }