@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
@@ -12,6 +12,9 @@
12
12
  import { EventEmitter } from 'node:events';
13
13
 
14
14
  import { openTransport, type McplTransport, type TransportCloseInfo } from './transport.js';
15
+ import { maskNegotiatedCapabilities } from './capability-mask.js';
16
+ import { CapabilityGrant, expandAdvertisementShorthand } from './capability-grant.js';
17
+ import { CAPABILITY_DISABLED } from './errors.js';
15
18
 
16
19
  import type {
17
20
  McplServerConfig,
@@ -21,10 +24,10 @@ import type {
21
24
  JsonRpcResponse,
22
25
  BeforeInferenceParams,
23
26
  BeforeInferenceResult,
24
- AfterInferenceParams,
25
- AfterInferenceResult,
26
27
  FeatureSetsUpdateParams,
28
+ FeatureSetsUpdateResult,
27
29
  InferenceChunkParams,
30
+ InferenceLifecycleParams,
28
31
  StateRollbackParams,
29
32
  StateRollbackResult,
30
33
  ChannelsOpenParams,
@@ -88,7 +91,6 @@ interface RequestOptions {
88
91
  * - `'channels-register'` — Server sent `channels/register`
89
92
  * - `'channels-changed'` — Server sent `channels/changed`
90
93
  * - `'channels-incoming'` — Server sent `channels/incoming`
91
- * - `'feature-sets-changed'` — Server sent `featureSets/changed`
92
94
  * - `'error'` — Connection-level error
93
95
  * - `'close'` — Connection closed
94
96
  * - `'connect-failed'` — Initial connect failed; background retry scheduled
@@ -99,9 +101,77 @@ export class McplServerConnection extends EventEmitter {
99
101
  /** Unique server identifier from config. */
100
102
  readonly id: string;
101
103
 
102
- /** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
104
+ /** MCPL capabilities negotiated during the initialize handshake — after
105
+ * host-side capability scoping (enabledCapabilities/disabledCapabilities)
106
+ * — or null if the server does not support MCPL. */
103
107
  capabilities: McplCapabilities | null;
104
108
 
109
+ /** Dotted paths of advertised capabilities the host config masked at the
110
+ * last handshake (empty when no scoping is configured). Inbound methods
111
+ * gated by a masked capability are rejected in setupMessageRouting. */
112
+ droppedCapabilities: ReadonlySet<string> = new Set();
113
+
114
+ /** OUTER standard MCP capabilities.tools was present at handshake (§5.1 —
115
+ * the sole source of the `tools` capability path). */
116
+ mcpToolsAdvertised = false;
117
+
118
+ /** Host-owned per-server authority for host/command (config
119
+ * allowHostCommands, default false). No capability path exists and the
120
+ * grant cannot confer it — server params never decide admin authority. */
121
+ allowHostCommands = false;
122
+
123
+ /**
124
+ * The effective capability grant (§5.4) — the sole authorization
125
+ * allowlist for this connection. Starts EMPTY: until the initial policy
126
+ * exchange completes (§5.3) nothing is granted and every privileged
127
+ * inbound method is rejected. The framework computes the grant from the
128
+ * masked advertisement and activates it via establishGrant() only after
129
+ * the featureSets/update Request is answered (expansion activates on
130
+ * receipt, §6.7); revocations re-call establishGrant() BEFORE the
131
+ * corresponding Request is sent (reduction applies first, §6.7).
132
+ */
133
+ grant: CapabilityGrant = CapabilityGrant.empty();
134
+
135
+ /** True once the §5.3 initial policy Request has been answered. */
136
+ policyEstablished = false;
137
+
138
+ /**
139
+ * §17 manifest tracking (host side of RFC-003): what this host actually
140
+ * fetched and acted on — never the server's announcement log (§17.10:
141
+ * those are different facts). Read by the legibility surface.
142
+ */
143
+ manifestState: {
144
+ /** revision of the last VALIDATED fetched manifest (server-authored,
145
+ * untrusted, equality-only) — null until a §17.4 fetch succeeds. */
146
+ lastValidatedRevision: string | null;
147
+ lastFetchedAt: number | null;
148
+ /** epoch ms of the last §6.7 receipt that activated a grant change. */
149
+ lastNegotiatedAt: number | null;
150
+ } = { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null };
151
+
152
+ /**
153
+ * Activate (or replace) the effective grant. Ordering is the caller's
154
+ * contract per §6.7: call BEFORE sending a reducing featureSets/update,
155
+ * AFTER the receipt for an expanding one.
156
+ */
157
+ establishGrant(grant: CapabilityGrant): void {
158
+ this.grant = grant;
159
+ this.policyEstablished = true;
160
+ }
161
+
162
+ /** A §5.3 grant belongs to one initialized transport epoch. Reconnecting
163
+ * performs a fresh initialize handshake, so authority from the dead epoch
164
+ * must be revoked before the replacement transport is exposed. */
165
+ private resetPolicyForTransportBoundary(): void {
166
+ this.grant = CapabilityGrant.empty();
167
+ this.policyEstablished = false;
168
+ // §17 tracking is per-epoch for the same reason the grant is: these are
169
+ // facts about what THIS host fetched/negotiated on THIS initialized
170
+ // transport, and a fresh initialize starts a fresh manifest history
171
+ // (Sol, PR #84 review — they previously survived into the new epoch).
172
+ this.manifestState = { lastValidatedRevision: null, lastFetchedAt: null, lastNegotiatedAt: null };
173
+ }
174
+
105
175
  /** The active transport (stdio child or WebSocket). Null for a disconnected
106
176
  * stub awaiting its first background reconnect. */
107
177
  private transport: McplTransport | null;
@@ -197,18 +267,15 @@ export class McplServerConnection extends EventEmitter {
197
267
  readyControlPlane(): void {
198
268
  this.controlPlaneReady = true;
199
269
  const pending = this.bufferedEvents;
200
- const deferred: Array<{ event: string; args: unknown[] }> = [];
201
270
  this.bufferedEvents = [];
202
271
  for (const item of pending) {
203
- if (McplServerConnection.isControlPlaneEvent(item.event)) {
204
- super.emit(item.event, ...item.args);
205
- } else {
206
- deferred.push(item);
207
- }
272
+ // Re-enter the ADMISSION gate (this.emit), never super.emit: control
273
+ // events include channels-register/changed, which are grant-gated —
274
+ // the old direct flush bypassed positive-grant enforcement entirely
275
+ // (PR #79 review blocker 3). Data-plane items re-buffer in order via
276
+ // the same call, since dataPlaneReady is still false.
277
+ this.emit(item.event, ...item.args);
208
278
  }
209
- // Data events emitted by control handlers while the snapshot was flushing
210
- // were appended to the new buffer. Preserve their order behind older data.
211
- this.bufferedEvents = [...deferred, ...this.bufferedEvents];
212
279
  }
213
280
 
214
281
  /**
@@ -233,18 +300,62 @@ export class McplServerConnection extends EventEmitter {
233
300
  ? this.controlPlaneReady
234
301
  : this.dataPlaneReady)
235
302
  ) {
303
+ // Positive-grant admission (§5.4/§14.1), at the single choke-point
304
+ // every path shares: live routing AND buffer flushes re-enter here, so
305
+ // an event buffered while the connection was staged is authorized
306
+ // against the grant current at ADMISSION — by which time the §5.3
307
+ // policy exchange has settled. Rejecting earlier (at line receipt)
308
+ // answered staged traffic with -32002 before the grant existed, which
309
+ // both bounced conforming servers' startup pushes and made a FAILED
310
+ // startup observable to the server (a response where the ledger said
311
+ // nothing was released).
312
+ // host/command: host-owned authority, not a capability — no §6.2 path
313
+ // exists and none is invented here. Default DENY; only the operator's
314
+ // config confers it (PR #79 review blocker 9: any server could
315
+ // request undo/hide/unstick).
316
+ if (name === 'host-command' && !this.allowHostCommands) {
317
+ const responder = args[1] as { respondError?: (code: number, message: string, data?: unknown) => void } | undefined;
318
+ if (responder?.respondError) {
319
+ responder.respondError(CAPABILITY_DISABLED, 'host/command requires host-owned authority (McplServerConfig.allowHostCommands)', {});
320
+ } else {
321
+ console.error(`[mcpl] ${this.id}: discarded host/command — allowHostCommands not set`);
322
+ }
323
+ return true;
324
+ }
325
+ const requiredCap = McplServerConnection.EVENT_TO_REQUIRED_CAPABILITY[name];
326
+ if (requiredCap && !this.grant.has(requiredCap)) {
327
+ const phase = this.policyEstablished ? 'not in the effective grant' : 'initial policy not yet established (§5.3)';
328
+ const responder = args[1] as { respondError?: (code: number, message: string, data?: unknown) => void } | undefined;
329
+ if (responder?.respondError) {
330
+ responder.respondError(CAPABILITY_DISABLED, `${name} rejected: capability "${requiredCap}" ${phase}`, { capability: requiredCap });
331
+ } else {
332
+ console.error(`[mcpl] ${this.id}: discarded ${name} — "${requiredCap}" ${phase}`);
333
+ }
334
+ return true;
335
+ }
236
336
  return super.emit(event, ...args);
237
337
  }
238
338
  this.bufferedEvents.push({ event: name, args });
239
339
  return true;
240
340
  }
241
341
 
342
+ /** Event-name form of METHOD_TO_REQUIRED_CAPABILITY, for admission-time
343
+ * enforcement in emit(). */
344
+ private static readonly EVENT_TO_REQUIRED_CAPABILITY: Record<string, string> = {
345
+ 'push-event': 'pushEvents',
346
+ 'inference-request': 'inferenceRequest',
347
+ 'model-info': 'modelInfo',
348
+ 'channels-register': 'channels.register',
349
+ 'channels-changed': 'channels.register',
350
+ 'channels-incoming': 'channels.incoming',
351
+ 'channels-list': 'channels.register',
352
+ };
353
+
242
354
  private static isControlPlaneEvent(event: string): boolean {
243
355
  return event === 'stderr'
244
356
  || event === 'scope-elevate'
245
357
  || event === 'channels-register'
246
358
  || event === 'channels-changed'
247
- || event === 'feature-sets-changed'
248
359
  || event === 'tools-list-changed'
249
360
  || event === 'connect-failed'
250
361
  || event === 'reconnect-failed'
@@ -273,9 +384,12 @@ export class McplServerConnection extends EventEmitter {
273
384
  config: McplServerConfig,
274
385
  hostCapabilities: McplHostCapabilities,
275
386
  ): Promise<McplServerConnection> {
276
- const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
387
+ const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake(config, hostCapabilities);
277
388
 
278
389
  const connection = new McplServerConnection(config.id, capabilities, transport);
390
+ connection.droppedCapabilities = droppedCapabilities;
391
+ connection.mcpToolsAdvertised = mcpToolsAdvertised;
392
+ connection.allowHostCommands = config.allowHostCommands === true;
279
393
  connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
280
394
 
281
395
  // Store config for reconnection
@@ -300,7 +414,7 @@ export class McplServerConnection extends EventEmitter {
300
414
  private static async handshake(
301
415
  config: McplServerConfig,
302
416
  hostCapabilities: McplHostCapabilities,
303
- ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null }> {
417
+ ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null; droppedCapabilities: ReadonlySet<string>; mcpToolsAdvertised: boolean }> {
304
418
  const transport = await openTransport(config);
305
419
 
306
420
  try {
@@ -318,7 +432,7 @@ export class McplServerConnection extends EventEmitter {
318
432
 
319
433
  // Await the initialize response, racing an early transport close/error and
320
434
  // a timeout. All three paths clean up their listeners.
321
- const capabilities = await new Promise<McplCapabilities | null>((resolve, reject) => {
435
+ const { mcpl: rawCapabilities, mcpToolsAdvertised } = await new Promise<{ mcpl: McplCapabilities | null; mcpToolsAdvertised: boolean }>((resolve, reject) => {
322
436
  const cleanup = () => {
323
437
  transport.off('line', onLine);
324
438
  transport.off('close', onClose);
@@ -341,7 +455,13 @@ export class McplServerConnection extends EventEmitter {
341
455
  const result = msg.result as Record<string, unknown> | undefined;
342
456
  const caps = result?.capabilities as Record<string, unknown> | undefined;
343
457
  const experimental = caps?.experimental as Record<string, unknown> | undefined;
344
- resolve((experimental?.mcpl as McplCapabilities) ?? null);
458
+ // §5.1: `tools` capability is the OUTER standard MCP member — the
459
+ // experimental manifest cannot mint it. Carried alongside so the
460
+ // grant can join it into §6.4 derivation.
461
+ resolve({
462
+ mcpl: (experimental?.mcpl as McplCapabilities) ?? null,
463
+ mcpToolsAdvertised: caps?.tools !== undefined,
464
+ });
345
465
  };
346
466
  const onClose = (info: TransportCloseInfo) => {
347
467
  cleanup();
@@ -365,7 +485,20 @@ export class McplServerConnection extends EventEmitter {
365
485
  // Send `initialized` notification (no id)
366
486
  transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
367
487
 
368
- return { transport, capabilities };
488
+ // §5.1 boolean shorthand expands to explicit leaves BEFORE the config
489
+ // mask runs, so sub-leaf policy is addressable — `channels: true` with
490
+ // `disabledCapabilities: ['channels.streaming']` must actually drop
491
+ // streaming (PR #79 review blocker 1).
492
+ const capabilities = expandAdvertisementShorthand(rawCapabilities);
493
+ // Host-side capability scoping: intersect the server's advertisement
494
+ // with config policy so a masked capability behaves exactly as if it
495
+ // had never been advertised (hooks, streaming, queries all read this).
496
+ const { capabilities: scoped, dropped } = maskNegotiatedCapabilities(capabilities, config);
497
+ if (dropped.length > 0) {
498
+ console.error(`MCPL server "${config.id}" capabilities masked by host config: ${dropped.join(', ')}`);
499
+ }
500
+
501
+ return { transport, capabilities: scoped, droppedCapabilities: new Set(dropped), mcpToolsAdvertised };
369
502
  } catch (err) {
370
503
  // Never leak the child / socket if the handshake fails.
371
504
  await transport.close().catch(() => { /* best effort */ });
@@ -436,29 +569,53 @@ export class McplServerConnection extends EventEmitter {
436
569
  return this.sendRequest(McplMethod.BeforeInference, params as unknown as Record<string, unknown>) as Promise<BeforeInferenceResult>;
437
570
  }
438
571
 
439
- /**
440
- * Send `context/afterInference`.
441
- * When `blocking` is true, sends as a request and awaits the result.
442
- * When `blocking` is false (or omitted), sends as a notification.
443
- */
444
- sendAfterInference(params: AfterInferenceParams, blocking?: boolean): Promise<AfterInferenceResult | void> {
445
- if (blocking) {
446
- return this.sendRequest(McplMethod.AfterInference, params as unknown as Record<string, unknown>) as Promise<AfterInferenceResult>;
447
- }
448
- this.sendNotification(McplMethod.AfterInference, params as unknown as Record<string, unknown>);
449
- return Promise.resolve();
450
- }
451
572
 
452
- /** Send `featureSets/update` notification. */
573
+ /** Send `featureSets/update` as a Notification. §6.7: valid ONLY for
574
+ * purely descriptive metadata that does not alter the grant. Any grant
575
+ * change — including the §5.3 initial policy — MUST use the Request form
576
+ * below. */
453
577
  sendFeatureSetsUpdate(params: FeatureSetsUpdateParams): void {
454
578
  this.sendNotification(McplMethod.FeatureSetsUpdate, params as unknown as Record<string, unknown>);
455
579
  }
456
580
 
581
+ /**
582
+ * Send `featureSets/update` as a Request (§5.3, §6.7) and await the
583
+ * degradation receipt. A short timeout, distinct from tool-call timeouts:
584
+ * a pre-0.5 server treats the Request as a notification and never
585
+ * answers, and per §6.7 an unanswered expansion simply does not activate
586
+ * — the caller leaves the grant empty and the connection MCP-only.
587
+ */
588
+ sendFeatureSetsUpdateRequest(
589
+ params: FeatureSetsUpdateParams,
590
+ timeoutMs = 15_000,
591
+ ): Promise<FeatureSetsUpdateResult> {
592
+ return this.sendRequest(
593
+ McplMethod.FeatureSetsUpdate,
594
+ params as unknown as Record<string, unknown>,
595
+ { timeoutMs },
596
+ ) as Promise<FeatureSetsUpdateResult>;
597
+ }
598
+
457
599
  /** Send `inference/chunk` notification. */
458
600
  sendInferenceChunk(params: InferenceChunkParams): void {
459
601
  this.sendNotification(McplMethod.InferenceChunk, params as unknown as Record<string, unknown>);
460
602
  }
461
603
 
604
+ /** Send `inference/lifecycle` notification (§10.5). Best-effort. */
605
+ sendInferenceLifecycle(params: InferenceLifecycleParams): void {
606
+ this.sendNotification(McplMethod.InferenceLifecycle, params as unknown as Record<string, unknown>);
607
+ }
608
+
609
+ /**
610
+ * Fetch the server's complete current manifest (§17.4) — the
611
+ * experimental.mcpl object in the same shape initialize carries, never a
612
+ * delta. The fetched CONTENT is authoritative for every decision; the
613
+ * revision is only the cheap path (§17.1).
614
+ */
615
+ sendManifestRequest(timeoutMs = 15_000): Promise<McplCapabilities | null> {
616
+ return this.sendRequest(McplMethod.Manifest, {}, { timeoutMs }) as Promise<McplCapabilities | null>;
617
+ }
618
+
462
619
  /** Send `state/rollback` request and await result. */
463
620
  sendStateRollback(params: StateRollbackParams): Promise<StateRollbackResult> {
464
621
  return this.sendRequest(McplMethod.StateRollback, params as unknown as Record<string, unknown>) as Promise<StateRollbackResult>;
@@ -585,6 +742,7 @@ export class McplServerConnection extends EventEmitter {
585
742
  return;
586
743
  }
587
744
  this.closed = true;
745
+ this.resetPolicyForTransportBoundary();
588
746
 
589
747
  // Reject all pending requests
590
748
  for (const [id, pending] of this.pendingRequests) {
@@ -663,7 +821,7 @@ export class McplServerConnection extends EventEmitter {
663
821
  const attempt = Math.max(1, this.reconnectAttempts);
664
822
 
665
823
  try {
666
- const { transport, capabilities } = await McplServerConnection.handshake(
824
+ const { transport, capabilities, droppedCapabilities, mcpToolsAdvertised } = await McplServerConnection.handshake(
667
825
  this.config,
668
826
  this.hostCapabilities,
669
827
  );
@@ -673,8 +831,12 @@ export class McplServerConnection extends EventEmitter {
673
831
  // awareness barrier synchronously, then releases control traffic needed
674
832
  // to establish the server while data remains held.
675
833
  this.pauseDataPlane();
834
+ this.resetPolicyForTransportBoundary();
676
835
  this.transport = transport;
677
836
  this.capabilities = capabilities;
837
+ this.droppedCapabilities = droppedCapabilities;
838
+ this.mcpToolsAdvertised = mcpToolsAdvertised;
839
+ this.allowHostCommands = this.config?.allowHostCommands === true;
678
840
  this.closed = false;
679
841
  this.nextRequestId = 1;
680
842
  this.pendingRequests.clear();
@@ -779,7 +941,16 @@ export class McplServerConnection extends EventEmitter {
779
941
  [McplMethod.ChannelsRegister]: 'channels-register',
780
942
  [McplMethod.ChannelsChanged]: 'channels-changed',
781
943
  [McplMethod.ChannelsIncoming]: 'channels-incoming',
782
- [McplMethod.FeatureSetsChanged]: 'feature-sets-changed',
944
+ // §6.6/§12: a request bearing an id MUST get a result or an error —
945
+ // neither of these had an entry, so callers hung forever (AUDIT-001
946
+ // item 4, which also poisoned the audit's own usage evidence).
947
+ [McplMethod.ModelInfo]: 'model-info',
948
+ // §17.3: deliberately UNGATED (not in EVENT_TO_REQUIRED_CAPABILITY) —
949
+ // announcing conveys no authority, and gating it would silence exactly
950
+ // the servers whose grants just narrowed. The cost is the host's
951
+ // re-fetch, which the framework rate-limits (§17.8).
952
+ [McplMethod.ManifestChanged]: 'manifest-changed',
953
+ [McplMethod.ChannelsList]: 'channels-list',
783
954
  'notifications/tools/list_changed': 'tools-list-changed',
784
955
  // Host-level admin commands initiated from a surface (e.g. a Discord
785
956
  // slash command). Params: { command, ... }; host responds with a
@@ -787,6 +958,33 @@ export class McplServerConnection extends EventEmitter {
787
958
  'host/command': 'host-command',
788
959
  };
789
960
 
961
+ /**
962
+ * Inbound (server-initiated) method → required capability path, per SPEC
963
+ * §14.1's table plus the non-channel privileged methods. **Positive-grant
964
+ * enforcement** (§5.4): the method is admitted only when the effective
965
+ * grant contains the named path — absence is denial, and before the
966
+ * initial policy exchange completes the grant is empty, so everything
967
+ * privileged is rejected (§5.3).
968
+ *
969
+ * This replaces the earlier `droppedCapabilities` deny-list check, which
970
+ * could only deny what had been advertised-then-masked: a server that
971
+ * never advertised a capability at all was never denied. (Adjudicated in
972
+ * review of PR #75; the mask itself is retained as the config-policy
973
+ * layer feeding the grant.)
974
+ *
975
+ * Channel methods key on their specific sub-capabilities, not the bare
976
+ * `channels` parent — a bare parent grants nothing (§5.4).
977
+ */
978
+ private static readonly METHOD_TO_REQUIRED_CAPABILITY: Record<string, string> = {
979
+ [McplMethod.PushEvent]: 'pushEvents',
980
+ [McplMethod.InferenceRequest]: 'inferenceRequest',
981
+ [McplMethod.ModelInfo]: 'modelInfo',
982
+ [McplMethod.ChannelsRegister]: 'channels.register',
983
+ [McplMethod.ChannelsChanged]: 'channels.register',
984
+ [McplMethod.ChannelsIncoming]: 'channels.incoming',
985
+ [McplMethod.ChannelsList]: 'channels.register',
986
+ };
987
+
790
988
  /**
791
989
  * Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
792
990
  * transport. Called from the constructor and again from attemptReconnect when
@@ -823,6 +1021,35 @@ export class McplServerConnection extends EventEmitter {
823
1021
  const request = msg as JsonRpcRequest;
824
1022
  const eventName = McplServerConnection.METHOD_TO_EVENT[request.method];
825
1023
 
1024
+ // §7 is removed in 0.5.0: scope/elevate is no longer a method. Answer
1025
+ // rather than hang (§6.6 — a method that will never be answered MUST
1026
+ // return an error).
1027
+ // featureSets/changed is REMOVED in 0.5.0 (§6.7 — it carried a
1028
+ // server-authored change payload, the self-attestation defect; the
1029
+ // need is met by §17's host-diffed manifest fetch). It was still
1030
+ // routed, control-plane classified, and mutating declarations — a
1031
+ // live ungated self-attestation path (PR #79 review blocker 4).
1032
+ if (request.method === McplMethod.FeatureSetsChanged) {
1033
+ if (request.id != null) {
1034
+ this.sendErrorResponse(request.id, -32601, 'featureSets/changed removed in MCPL 0.5.0 (superseded by mcpl/manifestChanged, SPEC §17)');
1035
+ } else {
1036
+ console.error(`[mcpl] ${this.id}: discarded featureSets/changed — removed in 0.5.0 (§6.7/§17)`);
1037
+ }
1038
+ return;
1039
+ }
1040
+
1041
+ if (request.method === McplMethod.ScopeElevate) {
1042
+ if (request.id != null) {
1043
+ this.sendErrorResponse(request.id, -32601, 'scope/elevate removed in MCPL 0.5.0 (SPEC §7)');
1044
+ }
1045
+ return;
1046
+ }
1047
+
1048
+ // Positive-grant enforcement (§5.4, §14.1) happens at ADMISSION, in
1049
+ // the emit() override below — the one choke-point that live routing
1050
+ // and staged-buffer flushes share. See the comment there for why
1051
+ // rejecting here, at line receipt, was wrong for staged connections.
1052
+
826
1053
  if (eventName) {
827
1054
  // Emit the typed event with params and (for requests) a respond callback
828
1055
  if (request.id != null) {
@@ -928,6 +1155,7 @@ export class McplServerConnection extends EventEmitter {
928
1155
  transport.on('close', (info: TransportCloseInfo) => {
929
1156
  if (!this.closed) {
930
1157
  this.closed = true;
1158
+ this.resetPolicyForTransportBoundary();
931
1159
 
932
1160
  // Reject all pending requests
933
1161
  for (const [id, pending] of this.pendingRequests) {
@@ -107,9 +107,10 @@ export class McplServerRegistry {
107
107
  case 'pushEvents':
108
108
  return caps.pushEvents === true;
109
109
  case 'contextHooks.beforeInference':
110
- return caps.contextHooks?.beforeInference === true;
111
- case 'contextHooks.afterInference':
112
- return !!caps.contextHooks?.afterInference;
110
+ // Truthy, not ===true: §5.1 shorthand is expanded to the object
111
+ // form at handshake. (Prefer connection.grant for authorization —
112
+ // this query is advertisement-level only.)
113
+ return !!caps.contextHooks?.beforeInference;
113
114
  case 'inferenceRequest':
114
115
  return !!caps.inferenceRequest;
115
116
  case 'modelInfo':
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Event-tag core closure (SPEC §16.3) and the addressed/ambient resolution.
3
+ *
4
+ * The `chat:*` core implications are NORMATIVE and spec-defined — a host
5
+ * expands them itself and never needs the producer's ontology to do so.
6
+ * Producer `implies` edges are advisory pending explicit acceptance (§16.4)
7
+ * and are NOT consumed here. Producer `suggestedTreatment` MUST NOT be
8
+ * auto-applied (§16.5) — nothing in this module reads it, deliberately.
9
+ *
10
+ * Tags are never authority (§16.6): expansion happens after admission, and
11
+ * nothing downstream may grant, admit, or authorize based on a tag.
12
+ */
13
+
14
+ /** Normative core closure edges (§16.3). Closed vocabulary — chat:* only. */
15
+ const CORE_IMPLIES: Readonly<Record<string, readonly string[]>> = {
16
+ 'chat:mention': ['chat:addressed'],
17
+ 'chat:reply': ['chat:addressed'],
18
+ 'chat:dm': ['chat:addressed', 'chat:private'],
19
+ };
20
+
21
+ /**
22
+ * Expand a raw tag list over the normative core closure, then resolve the
23
+ * §16.2 mutual exclusion: `chat:addressed` and `chat:ambient` cannot both
24
+ * stand after expansion, and addressed wins — a message that is both
25
+ * specifically-to-you and background noise is specifically-to-you.
26
+ *
27
+ * Pure; input order is preserved for the tags that survive, with implied
28
+ * tags appended in first-cause order. Unknown and producer-namespaced tags
29
+ * pass through untouched.
30
+ */
31
+ export function expandCoreTags(tags: readonly string[] | undefined): string[] {
32
+ if (!tags || tags.length === 0) return [];
33
+ const out: string[] = [];
34
+ const seen = new Set<string>();
35
+ const add = (t: string): void => {
36
+ if (!seen.has(t)) {
37
+ seen.add(t);
38
+ out.push(t);
39
+ }
40
+ };
41
+ for (const t of tags) {
42
+ add(t);
43
+ for (const implied of CORE_IMPLIES[t] ?? []) add(implied);
44
+ }
45
+ if (seen.has('chat:addressed') && seen.has('chat:ambient')) {
46
+ const i = out.indexOf('chat:ambient');
47
+ if (i >= 0) out.splice(i, 1);
48
+ }
49
+ return out;
50
+ }
@@ -222,12 +222,22 @@ export class WebSocketTransport extends McplTransport {
222
222
  const redacted = buildWebSocketUrl({ ...config, token: undefined }, null);
223
223
  const ws = new WebSocket(url);
224
224
 
225
+ // A failed dial can emit 'error' MORE THAN ONCE on the raw socket (seen
226
+ // live 2026-08-03: an HTTP 502 upgrade-reject emits the "Expected 101"
227
+ // error and then a teardown error). WebSocket is an EventEmitter, so any
228
+ // 'error' with no listener throws at the top level and KILLS THE WHOLE
229
+ // AGENT PROCESS — a crashloop, since the dial happens at boot. Every
230
+ // settle path below must therefore leave a swallow listener behind; a
231
+ // remote server being down must never be able to take the host down.
232
+ const swallowLateErrors = () => { /* settled; retry loop owns recovery */ };
233
+
225
234
  return new Promise<WebSocketTransport>((resolve, reject) => {
226
235
  let settled = false;
227
236
  const timer = setTimeout(() => {
228
237
  if (settled) return;
229
238
  settled = true;
230
239
  ws.removeAllListeners();
240
+ ws.on('error', swallowLateErrors);
231
241
  try { ws.terminate(); } catch { /* already gone */ }
232
242
  reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`));
233
243
  }, WS_OPEN_TIMEOUT_MS);
@@ -245,6 +255,7 @@ export class WebSocketTransport extends McplTransport {
245
255
  settled = true;
246
256
  clearTimeout(timer);
247
257
  ws.removeAllListeners();
258
+ ws.on('error', swallowLateErrors);
248
259
  try { ws.terminate(); } catch { /* already gone */ }
249
260
  reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`));
250
261
  });