@animalabs/agent-framework 0.1.0 → 0.3.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 (102) hide show
  1. package/dist/src/framework.d.ts +13 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +133 -10
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +5 -0
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +104 -6
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/types.d.ts +31 -0
  10. package/dist/src/gate/types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +3 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +4 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/channel-registry.d.ts +16 -2
  16. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  17. package/dist/src/mcpl/channel-registry.js +101 -6
  18. package/dist/src/mcpl/channel-registry.js.map +1 -1
  19. package/dist/src/mcpl/push-handler.js +1 -1
  20. package/dist/src/mcpl/push-handler.js.map +1 -1
  21. package/dist/src/mcpl/server-connection.d.ts +17 -2
  22. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  23. package/dist/src/mcpl/server-connection.js +66 -3
  24. package/dist/src/mcpl/server-connection.js.map +1 -1
  25. package/dist/src/mcpl/tool-policy.d.ts +24 -0
  26. package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
  27. package/dist/src/mcpl/tool-policy.js +46 -0
  28. package/dist/src/mcpl/tool-policy.js.map +1 -0
  29. package/dist/src/mcpl/types.d.ts +31 -0
  30. package/dist/src/mcpl/types.d.ts.map +1 -1
  31. package/dist/src/mcpl/types.js.map +1 -1
  32. package/dist/src/modules/workspace/index.d.ts +43 -1
  33. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/index.js +246 -32
  35. package/dist/src/modules/workspace/index.js.map +1 -1
  36. package/dist/src/modules/workspace/sync.d.ts +7 -2
  37. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  38. package/dist/src/modules/workspace/sync.js +2 -2
  39. package/dist/src/modules/workspace/sync.js.map +1 -1
  40. package/dist/src/modules/workspace/types.d.ts +55 -3
  41. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/types.js +8 -1
  43. package/dist/src/modules/workspace/types.js.map +1 -1
  44. package/dist/src/modules/workspace/watcher.d.ts +40 -3
  45. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  46. package/dist/src/modules/workspace/watcher.js +80 -13
  47. package/dist/src/modules/workspace/watcher.js.map +1 -1
  48. package/dist/src/types/trace.d.ts +20 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/src/usage/index.d.ts +3 -0
  51. package/dist/src/usage/index.d.ts.map +1 -0
  52. package/dist/src/usage/index.js +2 -0
  53. package/dist/src/usage/index.js.map +1 -0
  54. package/dist/src/usage/types.d.ts +28 -0
  55. package/dist/src/usage/types.d.ts.map +1 -0
  56. package/dist/src/usage/types.js +2 -0
  57. package/dist/src/usage/types.js.map +1 -0
  58. package/dist/src/usage/usage-tracker.d.ts +33 -0
  59. package/dist/src/usage/usage-tracker.d.ts.map +1 -0
  60. package/dist/src/usage/usage-tracker.js +115 -0
  61. package/dist/src/usage/usage-tracker.js.map +1 -0
  62. package/dist/test/channel-registry-typing.test.d.ts +2 -0
  63. package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
  64. package/dist/test/channel-registry-typing.test.js +82 -0
  65. package/dist/test/channel-registry-typing.test.js.map +1 -0
  66. package/dist/test/event-gate.test.js +135 -16
  67. package/dist/test/event-gate.test.js.map +1 -1
  68. package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
  69. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
  70. package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
  71. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
  72. package/dist/test/tool-policy.test.d.ts +2 -0
  73. package/dist/test/tool-policy.test.d.ts.map +1 -0
  74. package/dist/test/tool-policy.test.js +51 -0
  75. package/dist/test/tool-policy.test.js.map +1 -0
  76. package/dist/test/usage-tracker.test.d.ts +2 -0
  77. package/dist/test/usage-tracker.test.d.ts.map +1 -0
  78. package/dist/test/usage-tracker.test.js +129 -0
  79. package/dist/test/usage-tracker.test.js.map +1 -0
  80. package/dist/test/workspace-watcher.test.d.ts +2 -0
  81. package/dist/test/workspace-watcher.test.d.ts.map +1 -0
  82. package/dist/test/workspace-watcher.test.js +117 -0
  83. package/dist/test/workspace-watcher.test.js.map +1 -0
  84. package/dist/tsconfig.tsbuildinfo +1 -1
  85. package/package.json +3 -3
  86. package/src/framework.ts +148 -10
  87. package/src/gate/event-gate.ts +106 -6
  88. package/src/gate/types.ts +28 -0
  89. package/src/index.ts +7 -0
  90. package/src/mcpl/channel-registry.ts +120 -8
  91. package/src/mcpl/push-handler.ts +1 -1
  92. package/src/mcpl/server-connection.ts +78 -3
  93. package/src/mcpl/tool-policy.ts +52 -0
  94. package/src/mcpl/types.ts +34 -0
  95. package/src/modules/workspace/index.ts +269 -38
  96. package/src/modules/workspace/sync.ts +11 -4
  97. package/src/modules/workspace/types.ts +70 -3
  98. package/src/modules/workspace/watcher.ts +104 -15
  99. package/src/types/trace.ts +26 -1
  100. package/src/usage/index.ts +2 -0
  101. package/src/usage/types.ts +27 -0
  102. package/src/usage/usage-tracker.ts +141 -0
@@ -38,6 +38,21 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js
38
38
 
39
39
  const TYPING_INTERVAL_MS = 7_000;
40
40
 
41
+ function shallowEqualRecord(
42
+ a: Record<string, unknown> | undefined,
43
+ b: Record<string, unknown> | undefined,
44
+ ): boolean {
45
+ if (a === b) return true;
46
+ if (!a || !b) return false;
47
+ const keysA = Object.keys(a);
48
+ const keysB = Object.keys(b);
49
+ if (keysA.length !== keysB.length) return false;
50
+ for (const k of keysA) {
51
+ if (a[k] !== b[k]) return false;
52
+ }
53
+ return true;
54
+ }
55
+
41
56
  // ============================================================================
42
57
  // Internal Types
43
58
  // ============================================================================
@@ -179,7 +194,12 @@ export class ChannelRegistry {
179
194
  private featureSetManager: FeatureSetManager;
180
195
  private pushEventFn: (event: ProcessEvent) => void;
181
196
  private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
182
- private sendTypingFn?: (serverId: string, channelId: string) => void;
197
+ private sendTypingFn?: (
198
+ serverId: string,
199
+ channelId: string,
200
+ metadata?: Record<string, unknown>,
201
+ op?: 'start' | 'stop',
202
+ ) => void;
183
203
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
184
204
 
185
205
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
@@ -195,13 +215,29 @@ export class ChannelRegistry {
195
215
  /** Per-channel typing indicator timers. */
196
216
  private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
197
217
 
218
+ /** Per-channel typing metadata — carried on the 7s refresh so the target
219
+ * server keeps getting the same routing hints (e.g. Zulip topic). */
220
+ private typingMetadata = new Map<string, Record<string, unknown>>();
221
+
222
+ /**
223
+ * Per-server channel auto-open policy. Populated by the framework from
224
+ * each McplServerConfig at connect time; default ('auto') applies when
225
+ * unset.
226
+ */
227
+ private subscriptionPolicies = new Map<string, 'auto' | 'manual' | string[]>();
228
+
198
229
  constructor(
199
230
  serverRegistry: McplServerRegistry,
200
231
  featureSetManager: FeatureSetManager,
201
232
  pushEventFn: (event: ProcessEvent) => void,
202
233
  emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
203
234
  options?: ChannelRegistryOptions & {
204
- sendTypingFn?: (serverId: string, channelId: string) => void;
235
+ sendTypingFn?: (
236
+ serverId: string,
237
+ channelId: string,
238
+ metadata?: Record<string, unknown>,
239
+ op?: 'start' | 'stop',
240
+ ) => void;
205
241
  },
206
242
  ) {
207
243
  this.serverRegistry = serverRegistry;
@@ -212,6 +248,14 @@ export class ChannelRegistry {
212
248
  this.shouldTriggerInference = options?.shouldTriggerInference;
213
249
  }
214
250
 
251
+ /**
252
+ * Set the channel auto-open policy for a server. Called by the framework
253
+ * when each MCPL server is registered.
254
+ */
255
+ setSubscriptionPolicy(serverId: string, policy: 'auto' | 'manual' | string[]): void {
256
+ this.subscriptionPolicies.set(serverId, policy);
257
+ }
258
+
215
259
  // ==========================================================================
216
260
  // Handler Methods (called from framework.ts wireMcplEvents)
217
261
  // ==========================================================================
@@ -339,7 +383,7 @@ export class ChannelRegistry {
339
383
  textContent,
340
384
  {
341
385
  ...message.metadata,
342
- eventType: 'channel:incoming',
386
+ eventType: 'mcpl:channel-incoming',
343
387
  serverId,
344
388
  channelId: message.channelId,
345
389
  messageId: message.messageId,
@@ -398,9 +442,27 @@ export class ChannelRegistry {
398
442
  *
399
443
  * No-op if already typing on this channel.
400
444
  */
401
- startTyping(channelId: string): void {
445
+ startTyping(channelId: string, metadata?: Record<string, unknown>): void {
446
+ const metadataChanged =
447
+ metadata !== undefined &&
448
+ !shallowEqualRecord(this.typingMetadata.get(channelId), metadata);
449
+ if (metadata) {
450
+ this.typingMetadata.set(channelId, metadata);
451
+ }
452
+
402
453
  if (this.typingIntervals.has(channelId)) {
403
- return; // Already typing
454
+ // Already typing. If the caller supplied new routing metadata (e.g. the
455
+ // relevant Zulip topic just moved because a newer message arrived),
456
+ // dispatch an immediate refresh so the server sees the new routing
457
+ // within this request instead of waiting up to TYPING_INTERVAL_MS for
458
+ // the next tick.
459
+ if (metadataChanged) {
460
+ const entry = this.findChannelEntry(channelId);
461
+ if (entry) {
462
+ this.sendTypingNotification(entry.serverId, channelId);
463
+ }
464
+ }
465
+ return;
404
466
  }
405
467
 
406
468
  // Find the channel entry and its server
@@ -412,7 +474,9 @@ export class ChannelRegistry {
412
474
  // Send typing immediately
413
475
  this.sendTypingNotification(entry.serverId, channelId);
414
476
 
415
- // Set up interval
477
+ // Set up interval — pulls the latest metadata on each tick so mid-stream
478
+ // updates (e.g. a newer incoming message switching the relevant topic)
479
+ // take effect on the next refresh.
416
480
  const interval = setInterval(() => {
417
481
  this.sendTypingNotification(entry.serverId, channelId);
418
482
  }, TYPING_INTERVAL_MS);
@@ -432,13 +496,32 @@ export class ChannelRegistry {
432
496
  if (interval) {
433
497
  clearInterval(interval);
434
498
  this.typingIntervals.delete(channelId);
499
+ // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
500
+ // clear the indicator immediately rather than waiting for auto-expire.
501
+ // Metadata still carries the routing hint so the stop hits the same
502
+ // topic/thread as the start. Guarded by `interval`: matches the
503
+ // global-clear branch's semantics, and keeps defensive stopTyping(ch)
504
+ // calls from spamming stops at a server that never saw a start.
505
+ const entry = this.findChannelEntry(channelId);
506
+ if (entry && this.sendTypingFn) {
507
+ this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
508
+ }
435
509
  }
510
+ this.typingMetadata.delete(channelId);
436
511
  } else {
437
- // Clear all typing intervals
512
+ // Clear all typing intervals and dispatch stop for each known channel
513
+ const channels = Array.from(this.typingIntervals.keys());
438
514
  for (const interval of this.typingIntervals.values()) {
439
515
  clearInterval(interval);
440
516
  }
441
517
  this.typingIntervals.clear();
518
+ if (this.sendTypingFn) {
519
+ for (const id of channels) {
520
+ const entry = this.findChannelEntry(id);
521
+ if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
522
+ }
523
+ }
524
+ this.typingMetadata.clear();
442
525
  }
443
526
  }
444
527
 
@@ -574,7 +657,23 @@ export class ChannelRegistry {
574
657
  const server = this.serverRegistry.getServer(serverId);
575
658
  if (!server) return;
576
659
 
660
+ const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
661
+ if (policy === 'manual') {
662
+ this.emitTraceFn({
663
+ type: 'mcpl:channels-auto-open-skipped',
664
+ serverId,
665
+ reason: 'manual',
666
+ count: channels.length,
667
+ });
668
+ return;
669
+ }
670
+
671
+ const allowList = Array.isArray(policy) ? new Set(policy) : null;
672
+ let opened = 0;
673
+
577
674
  for (const channel of channels) {
675
+ if (allowList && !allowList.has(channel.id)) continue;
676
+
578
677
  const key = `${serverId}:${channel.id}`;
579
678
  try {
580
679
  await server.sendChannelsOpen({
@@ -585,6 +684,7 @@ export class ChannelRegistry {
585
684
  if (entry) {
586
685
  entry.open = true;
587
686
  }
687
+ opened++;
588
688
  } catch (err) {
589
689
  this.emitTraceFn({
590
690
  type: 'mcpl:channel-open-failed',
@@ -594,6 +694,18 @@ export class ChannelRegistry {
594
694
  });
595
695
  }
596
696
  }
697
+
698
+ // Surface the common typo failure mode: allow-list set but nothing
699
+ // matched. Without this trace a one-char typo in a channel id looks
700
+ // identical to "MCPL didn't register the channel".
701
+ if (allowList && opened === 0 && channels.length > 0) {
702
+ this.emitTraceFn({
703
+ type: 'mcpl:channels-allow-list-empty',
704
+ serverId,
705
+ allowList: [...allowList],
706
+ registered: channels.map(c => c.id),
707
+ });
708
+ }
597
709
  }
598
710
 
599
711
  // ==========================================================================
@@ -608,7 +720,7 @@ export class ChannelRegistry {
608
720
  */
609
721
  private sendTypingNotification(serverId: string, channelId: string): void {
610
722
  if (this.sendTypingFn) {
611
- this.sendTypingFn(serverId, channelId);
723
+ this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
612
724
  }
613
725
  // TODO: When server-connection exposes a public sendNotification or
614
726
  // sendTyping method, wire it here directly instead of using a callback.
@@ -195,7 +195,7 @@ export class PushHandler {
195
195
  serverId,
196
196
  featureSet: params.featureSet,
197
197
  eventId: params.eventId,
198
- eventType: 'push:event',
198
+ eventType: 'mcpl:push-event',
199
199
  ...(params.origin ?? {}),
200
200
  };
201
201
  triggerInference = this.shouldTriggerInference(textContent, metadata);
@@ -95,6 +95,9 @@ export class McplServerConnection extends EventEmitter {
95
95
  private reconnectIntervalMs = 5000;
96
96
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
97
 
98
+ /** Swaps the line handler on the current child's stderr pipe. Re-bound on reconnect. */
99
+ private rebindStderr: ((next: (line: string) => void) => void) | null = null;
100
+
98
101
  /**
99
102
  * Private constructor. Use `McplServerConnection.connect()` instead.
100
103
  */
@@ -151,6 +154,33 @@ export class McplServerConnection extends EventEmitter {
151
154
  // Static factory
152
155
  // ==========================================================================
153
156
 
157
+ /**
158
+ * Wire stderr forwarding on a child process. Lines are forwarded to the
159
+ * supplied callback. The callback can be swapped at any time via `rebind`
160
+ * (used during reconnect, when we want already-wired pipes to target a new
161
+ * emitter).
162
+ */
163
+ private static wireStderrForwarding(
164
+ child: ChildProcess,
165
+ initialLineHandler: (line: string) => void,
166
+ ): { rebind: (next: (line: string) => void) => void } {
167
+ let onLine = initialLineHandler;
168
+ let carry = '';
169
+ child.stderr?.on('data', (chunk: Buffer) => {
170
+ const text = carry + chunk.toString('utf8');
171
+ const lines = text.split('\n');
172
+ carry = lines.pop() ?? '';
173
+ for (const line of lines) {
174
+ if (line.length > 0) onLine(line);
175
+ }
176
+ });
177
+ return {
178
+ rebind(next) {
179
+ onLine = next;
180
+ },
181
+ };
182
+ }
183
+
154
184
  /**
155
185
  * Spawn a server process, perform the MCP initialize handshake with MCPL
156
186
  * capability negotiation, and return a ready-to-use connection.
@@ -166,6 +196,14 @@ export class McplServerConnection extends EventEmitter {
166
196
  env: { ...process.env, ...config.env },
167
197
  });
168
198
 
199
+ // Capture child stderr so it isn't silently dropped. During the handshake
200
+ // window (before the instance exists) lines are buffered; the buffer is
201
+ // drained once we have an instance to emit 'stderr' events on.
202
+ const earlyStderrBuffer: string[] = [];
203
+ const attachStderr = McplServerConnection.wireStderrForwarding(child, (line) =>
204
+ earlyStderrBuffer.push(line),
205
+ );
206
+
169
207
  // Fail fast if the process exits before the handshake completes
170
208
  const earlyExitPromise = new Promise<never>((_resolve, reject) => {
171
209
  child.on('error', (err) => {
@@ -251,6 +289,16 @@ export class McplServerConnection extends EventEmitter {
251
289
 
252
290
  const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
253
291
 
292
+ // Swap the stderr handler to emit events on the instance, then drain
293
+ // anything that arrived during the handshake window. Keep the rebind
294
+ // handle on the instance so reconnect can re-target it later.
295
+ connection.rebindStderr = attachStderr.rebind;
296
+ attachStderr.rebind((line: string) => connection.emit('stderr', { line }));
297
+ for (const line of earlyStderrBuffer) {
298
+ connection.emit('stderr', { line });
299
+ }
300
+ earlyStderrBuffer.length = 0;
301
+
254
302
  // Store config for reconnection
255
303
  if (config.reconnect) {
256
304
  connection.config = config;
@@ -395,9 +443,21 @@ export class McplServerConnection extends EventEmitter {
395
443
  this.sendNotification(McplMethod.ChannelsOutgoingComplete, params as unknown as Record<string, unknown>);
396
444
  }
397
445
 
398
- /** Send `channels/typing` notification (best-effort). */
399
- sendChannelsTyping(channelId: string): void {
400
- this.sendNotification(McplMethod.ChannelsTyping, { channelId });
446
+ /** Send `channels/typing` notification (best-effort).
447
+ * `metadata` is opaque routing hints for the server — e.g. Zulip uses
448
+ * `topic` to target a specific thread; other servers ignore what they don't
449
+ * recognize. Typically sourced from the most recent incoming message's
450
+ * metadata on the same channel.
451
+ * `op` defaults to 'start'; pass 'stop' to clear an active indicator
452
+ * immediately rather than waiting for server-side auto-expire. */
453
+ sendChannelsTyping(
454
+ channelId: string,
455
+ metadata?: Record<string, unknown>,
456
+ op: 'start' | 'stop' = 'start',
457
+ ): void {
458
+ const params: Record<string, unknown> = { channelId, op };
459
+ if (metadata) params.metadata = metadata;
460
+ this.sendNotification(McplMethod.ChannelsTyping, params);
401
461
  }
402
462
 
403
463
  // ==========================================================================
@@ -499,6 +559,21 @@ export class McplServerConnection extends EventEmitter {
499
559
  this.nextRequestId = 1;
500
560
  this.pendingRequests.clear();
501
561
 
562
+ // Re-target stderr forwarding from the throwaway `fresh` instance to `this`.
563
+ //
564
+ // Note: this follows the same "transfer internals, leak listeners on the
565
+ // dead instance" pattern as the message-routing and lifecycle rewiring
566
+ // below — `fresh`'s own readline 'line' handler and process 'exit'
567
+ // handler stay registered on the transferred child, short-circuited by
568
+ // `fresh.closed = true` set in extractInternals(). Rebinding fresh's
569
+ // stderr listener (rather than removing it) keeps that invariant: if we
570
+ // ever clean up the message-routing duplication, fresh's stderr data
571
+ // listener closure-holds onLine and would need the same teardown.
572
+ if (fresh.rebindStderr) {
573
+ fresh.rebindStderr((line: string) => this.emit('stderr', { line }));
574
+ this.rebindStderr = fresh.rebindStderr;
575
+ }
576
+
502
577
  // Re-wire message routing and lifecycle on the new process
503
578
  this.setupMessageRouting();
504
579
  this.setupLifecycle();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tool allow/deny policy for MCPL servers.
3
+ *
4
+ * `enabledTools` and `disabledTools` on McplServerConfig hold bare tool names
5
+ * (no toolPrefix) with `*` substring wildcards. This module owns the predicate
6
+ * used at both tool-list time and dispatch time.
7
+ *
8
+ * Semantics:
9
+ * - No fields set → all tools allowed.
10
+ * - Only enabledTools set → tool must match at least one pattern.
11
+ * - Only disabledTools set → tool must NOT match any pattern.
12
+ * - Both set → must match enabledTools AND not match disabledTools.
13
+ * (deny wins on overlap.)
14
+ */
15
+
16
+ /**
17
+ * Compile a pattern with `*` substring wildcards into a regex.
18
+ * `*` matches any run of characters (including empty); other characters are literal.
19
+ */
20
+ function compilePattern(pattern: string): RegExp {
21
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
22
+ return new RegExp(`^${escaped}$`);
23
+ }
24
+
25
+ function anyMatch(patterns: string[], name: string): boolean {
26
+ for (const p of patterns) {
27
+ if (compilePattern(p).test(name)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export interface ToolPolicy {
33
+ enabledTools?: string[];
34
+ disabledTools?: string[];
35
+ }
36
+
37
+ /**
38
+ * Returns true if `bareToolName` (server-native name, no prefix) is allowed
39
+ * under the supplied policy.
40
+ */
41
+ export function isToolAllowed(bareToolName: string, policy: ToolPolicy | undefined): boolean {
42
+ if (!policy) return true;
43
+ const { enabledTools, disabledTools } = policy;
44
+
45
+ if (disabledTools && disabledTools.length > 0 && anyMatch(disabledTools, bareToolName)) {
46
+ return false;
47
+ }
48
+ if (enabledTools && enabledTools.length > 0) {
49
+ return anyMatch(enabledTools, bareToolName);
50
+ }
51
+ return true;
52
+ }
package/src/mcpl/types.ts CHANGED
@@ -180,6 +180,24 @@ export interface McplServerConfig {
180
180
  /** Feature sets to explicitly disable on connect */
181
181
  disabledFeatureSets?: string[];
182
182
 
183
+ /**
184
+ * Tool allow-list (bare tool names as the server exports them, no toolPrefix).
185
+ * Supports `*` as a substring wildcard (e.g. `read_*`, `*_file`, `*`).
186
+ * If set, only tools matching at least one pattern are exposed.
187
+ * `disabledTools` takes precedence on conflict.
188
+ *
189
+ * Filter is applied in two places: at tool-list time (model never sees the
190
+ * tool) and at dispatch time (call rejected with a tool-result error, in
191
+ * case the model imitates a prior call from message history).
192
+ */
193
+ enabledTools?: string[];
194
+
195
+ /**
196
+ * Tool deny-list (bare tool names, same wildcard syntax as enabledTools).
197
+ * Wins over enabledTools on conflict.
198
+ */
199
+ disabledTools?: string[];
200
+
183
201
  /** Scope configurations per feature set */
184
202
  scopes?: Record<string, ScopeConfig>;
185
203
 
@@ -222,6 +240,22 @@ export interface McplServerConfig {
222
240
  * Must not collide with any module name or another server's prefix.
223
241
  */
224
242
  toolPrefix?: string;
243
+
244
+ /**
245
+ * How channels registered by this server are opened.
246
+ * - `'auto'` (default) — every registered channel is auto-opened; the host
247
+ * receives all traffic. Matches prior behavior. Right for agents that
248
+ * should listen passively (e.g. a frontdesk clerk).
249
+ * - `'manual'` — registered channels are listed but not opened. The agent
250
+ * must call the `channel_open` tool to start receiving messages. Right
251
+ * for focused agents that should not be flooded with chatter from
252
+ * servers that advertise many public channels (e.g. Zulip's full stream
253
+ * list).
254
+ * - `string[]` — allow-list of channel ids; only matching channels are
255
+ * auto-opened. Ids are server-scoped (the raw id the server registers,
256
+ * e.g. `"zulip:tracker-miner-f"`).
257
+ */
258
+ channelSubscription?: 'auto' | 'manual' | string[];
225
259
  }
226
260
 
227
261
  // ============================================================================