@animalabs/agent-framework 0.2.0 → 0.4.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.
- package/dist/src/framework.d.ts +8 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +42 -2
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +5 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +61 -5
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +17 -2
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +66 -3
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-policy.d.ts +24 -0
- package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
- package/dist/src/mcpl/tool-policy.js +46 -0
- package/dist/src/mcpl/tool-policy.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +16 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +7 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +81 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +11 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +13 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +27 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-typing.test.d.ts +2 -0
- package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-typing.test.js +82 -0
- package/dist/test/channel-registry-typing.test.js.map +1 -0
- package/dist/test/tool-policy.test.d.ts +2 -0
- package/dist/test/tool-policy.test.d.ts.map +1 -0
- package/dist/test/tool-policy.test.js +51 -0
- package/dist/test/tool-policy.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.js +28 -1
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/framework.ts +44 -2
- package/src/index.ts +3 -0
- package/src/mcpl/channel-registry.ts +75 -7
- package/src/mcpl/server-connection.ts +78 -3
- package/src/mcpl/tool-policy.ts +52 -0
- package/src/mcpl/types.ts +18 -0
- package/src/modules/workspace/index.ts +88 -4
- package/src/modules/workspace/types.ts +11 -0
- package/src/modules/workspace/watcher.ts +40 -0
- package/src/types/trace.ts +7 -0
package/src/framework.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
|
|
|
51
51
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
52
52
|
import { safeSlice } from './safe-slice.js';
|
|
53
53
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
54
|
+
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
54
55
|
import { EventGate } from './gate/event-gate.js';
|
|
55
56
|
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
|
|
56
57
|
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
|
|
@@ -473,6 +474,16 @@ export class AgentFramework {
|
|
|
473
474
|
this.traceListeners.push(listener);
|
|
474
475
|
}
|
|
475
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Public accessor for the MCPL channel registry.
|
|
479
|
+
* Null when no MCPL servers are configured.
|
|
480
|
+
* Modules that need channel-level operations (typing indicators, default publish channel,
|
|
481
|
+
* etc.) obtain them here.
|
|
482
|
+
*/
|
|
483
|
+
get channels(): ChannelRegistry | null {
|
|
484
|
+
return this.channelRegistry;
|
|
485
|
+
}
|
|
486
|
+
|
|
476
487
|
/**
|
|
477
488
|
* Remove a trace event listener.
|
|
478
489
|
*/
|
|
@@ -2101,6 +2112,13 @@ export class AgentFramework {
|
|
|
2101
2112
|
}
|
|
2102
2113
|
const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
|
|
2103
2114
|
const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
|
|
2115
|
+
if (!isToolAllowed(toolName, config)) {
|
|
2116
|
+
return {
|
|
2117
|
+
success: false,
|
|
2118
|
+
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
|
|
2119
|
+
isError: true,
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2104
2122
|
try {
|
|
2105
2123
|
const result = await server.sendToolsCall(toolName, call.input as Record<string, unknown>);
|
|
2106
2124
|
return {
|
|
@@ -2457,10 +2475,10 @@ export class AgentFramework {
|
|
|
2457
2475
|
(event) => this.pushEvent(event),
|
|
2458
2476
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
2459
2477
|
{
|
|
2460
|
-
sendTypingFn: (serverId, channelId) => {
|
|
2478
|
+
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
2461
2479
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
2462
2480
|
if (server) {
|
|
2463
|
-
server.sendChannelsTyping(channelId);
|
|
2481
|
+
server.sendChannelsTyping(channelId, metadata, op);
|
|
2464
2482
|
}
|
|
2465
2483
|
},
|
|
2466
2484
|
shouldTriggerInference: triggerFilter,
|
|
@@ -2547,6 +2565,12 @@ export class AgentFramework {
|
|
|
2547
2565
|
* Push events and inference requests are deferred to Steps 6/7.
|
|
2548
2566
|
*/
|
|
2549
2567
|
private wireMcplEvents(connection: McplServerConnection): void {
|
|
2568
|
+
// Forward subprocess stderr lines as trace events so consumers (conhost,
|
|
2569
|
+
// log sinks, TUI badges) can persist and surface them.
|
|
2570
|
+
connection.on('stderr', (params: { line: string }) => {
|
|
2571
|
+
this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2550
2574
|
// Handle dynamic feature set changes from server
|
|
2551
2575
|
connection.on('feature-sets-changed', (params: FeatureSetsChangedParams) => {
|
|
2552
2576
|
this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
|
|
@@ -2644,6 +2668,7 @@ export class AgentFramework {
|
|
|
2644
2668
|
try {
|
|
2645
2669
|
const result = await server.sendToolsList();
|
|
2646
2670
|
for (const tool of result.tools) {
|
|
2671
|
+
if (!isToolAllowed(tool.name, config)) continue;
|
|
2647
2672
|
// MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
|
|
2648
2673
|
const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
|
|
2649
2674
|
tools.push({
|
|
@@ -2733,6 +2758,23 @@ export class AgentFramework {
|
|
|
2733
2758
|
return;
|
|
2734
2759
|
}
|
|
2735
2760
|
|
|
2761
|
+
const config = this.mcplServerConfigs.get(serverId);
|
|
2762
|
+
if (!isToolAllowed(toolName, config)) {
|
|
2763
|
+
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
|
|
2764
|
+
this.pushEvent({
|
|
2765
|
+
type: 'tool-result',
|
|
2766
|
+
callId: call.id,
|
|
2767
|
+
agentName,
|
|
2768
|
+
moduleName: `mcpl:${serverId}`,
|
|
2769
|
+
result: {
|
|
2770
|
+
success: false,
|
|
2771
|
+
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
|
|
2772
|
+
isError: true,
|
|
2773
|
+
},
|
|
2774
|
+
});
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2736
2778
|
this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
|
|
2737
2779
|
const startTime = Date.now();
|
|
2738
2780
|
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,9 @@ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent
|
|
|
60
60
|
export { EventGate } from './gate/index.js';
|
|
61
61
|
export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js';
|
|
62
62
|
|
|
63
|
+
// MCPL channel registry (exposed for modules that need channel-level operations)
|
|
64
|
+
export { ChannelRegistry } from './mcpl/index.js';
|
|
65
|
+
|
|
63
66
|
// Re-export commonly used types from dependencies
|
|
64
67
|
export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/context-manager';
|
|
65
68
|
export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';
|
|
@@ -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?: (
|
|
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,6 +215,10 @@ 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
|
+
|
|
198
222
|
/**
|
|
199
223
|
* Per-server channel auto-open policy. Populated by the framework from
|
|
200
224
|
* each McplServerConfig at connect time; default ('auto') applies when
|
|
@@ -208,7 +232,12 @@ export class ChannelRegistry {
|
|
|
208
232
|
pushEventFn: (event: ProcessEvent) => void,
|
|
209
233
|
emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
|
|
210
234
|
options?: ChannelRegistryOptions & {
|
|
211
|
-
sendTypingFn?: (
|
|
235
|
+
sendTypingFn?: (
|
|
236
|
+
serverId: string,
|
|
237
|
+
channelId: string,
|
|
238
|
+
metadata?: Record<string, unknown>,
|
|
239
|
+
op?: 'start' | 'stop',
|
|
240
|
+
) => void;
|
|
212
241
|
},
|
|
213
242
|
) {
|
|
214
243
|
this.serverRegistry = serverRegistry;
|
|
@@ -413,9 +442,27 @@ export class ChannelRegistry {
|
|
|
413
442
|
*
|
|
414
443
|
* No-op if already typing on this channel.
|
|
415
444
|
*/
|
|
416
|
-
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
|
+
|
|
417
453
|
if (this.typingIntervals.has(channelId)) {
|
|
418
|
-
|
|
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;
|
|
419
466
|
}
|
|
420
467
|
|
|
421
468
|
// Find the channel entry and its server
|
|
@@ -427,7 +474,9 @@ export class ChannelRegistry {
|
|
|
427
474
|
// Send typing immediately
|
|
428
475
|
this.sendTypingNotification(entry.serverId, channelId);
|
|
429
476
|
|
|
430
|
-
// 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.
|
|
431
480
|
const interval = setInterval(() => {
|
|
432
481
|
this.sendTypingNotification(entry.serverId, channelId);
|
|
433
482
|
}, TYPING_INTERVAL_MS);
|
|
@@ -447,13 +496,32 @@ export class ChannelRegistry {
|
|
|
447
496
|
if (interval) {
|
|
448
497
|
clearInterval(interval);
|
|
449
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
|
+
}
|
|
450
509
|
}
|
|
510
|
+
this.typingMetadata.delete(channelId);
|
|
451
511
|
} else {
|
|
452
|
-
// Clear all typing intervals
|
|
512
|
+
// Clear all typing intervals and dispatch stop for each known channel
|
|
513
|
+
const channels = Array.from(this.typingIntervals.keys());
|
|
453
514
|
for (const interval of this.typingIntervals.values()) {
|
|
454
515
|
clearInterval(interval);
|
|
455
516
|
}
|
|
456
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();
|
|
457
525
|
}
|
|
458
526
|
}
|
|
459
527
|
|
|
@@ -652,7 +720,7 @@ export class ChannelRegistry {
|
|
|
652
720
|
*/
|
|
653
721
|
private sendTypingNotification(serverId: string, channelId: string): void {
|
|
654
722
|
if (this.sendTypingFn) {
|
|
655
|
-
this.sendTypingFn(serverId, channelId);
|
|
723
|
+
this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
|
|
656
724
|
}
|
|
657
725
|
// TODO: When server-connection exposes a public sendNotification or
|
|
658
726
|
// sendTyping method, wire it here directly instead of using a callback.
|
|
@@ -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
|
-
|
|
400
|
-
|
|
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
|
|
|
@@ -121,6 +121,8 @@ export class WorkspaceModule implements Module {
|
|
|
121
121
|
initialSyncDone: false,
|
|
122
122
|
lastMaterializedBranchId: null,
|
|
123
123
|
materializedHashes: new Map(),
|
|
124
|
+
watcherReadyAt: null,
|
|
125
|
+
watcherError: null,
|
|
124
126
|
};
|
|
125
127
|
this.mounts.set(mount.name, mountState);
|
|
126
128
|
}
|
|
@@ -140,6 +142,9 @@ export class WorkspaceModule implements Module {
|
|
|
140
142
|
if (mount) {
|
|
141
143
|
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
142
144
|
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
145
|
+
// watcherReadyAt intentionally not restored — each session must
|
|
146
|
+
// observe its own watcher attach, otherwise a stale timestamp
|
|
147
|
+
// would hide a new-session attach failure.
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
}
|
|
@@ -161,9 +166,27 @@ export class WorkspaceModule implements Module {
|
|
|
161
166
|
|
|
162
167
|
const watchMode = mount.config.watch ?? 'always';
|
|
163
168
|
if (watchMode === 'always') {
|
|
164
|
-
const watcher = new MountWatcher(
|
|
165
|
-
|
|
166
|
-
|
|
169
|
+
const watcher = new MountWatcher(
|
|
170
|
+
mount.config,
|
|
171
|
+
(changes) => {
|
|
172
|
+
this.handleFsChanges(name, changes);
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
onReady: () => {
|
|
176
|
+
mount.watcherReadyAt = Date.now();
|
|
177
|
+
mount.watcherError = null;
|
|
178
|
+
},
|
|
179
|
+
onError: (err) => {
|
|
180
|
+
mount.watcherError = err.message;
|
|
181
|
+
this.ctx?.pushEvent({
|
|
182
|
+
type: 'workspace:watcher-error',
|
|
183
|
+
mount: name,
|
|
184
|
+
path: mount.config.path,
|
|
185
|
+
error: err.message,
|
|
186
|
+
} as ProcessEvent);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
167
190
|
watcher.start();
|
|
168
191
|
this.watchers.set(name, watcher);
|
|
169
192
|
|
|
@@ -212,6 +235,8 @@ export class WorkspaceModule implements Module {
|
|
|
212
235
|
state.mounts[name] = {
|
|
213
236
|
lastMaterializedSeq: mount.lastMaterializedSeq,
|
|
214
237
|
lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined,
|
|
238
|
+
watcherReadyAt: mount.watcherReadyAt,
|
|
239
|
+
watcherError: mount.watcherError,
|
|
215
240
|
};
|
|
216
241
|
}
|
|
217
242
|
this.ctx.setState(state);
|
|
@@ -399,13 +424,72 @@ export class WorkspaceModule implements Module {
|
|
|
399
424
|
const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
|
|
400
425
|
if (!opAllowed) return {};
|
|
401
426
|
|
|
402
|
-
|
|
427
|
+
// Inject a context message so the model actually sees the event.
|
|
428
|
+
// The wake signal alone (requestInference) only starts an inference; the
|
|
429
|
+
// model needs textual evidence in its context to act on it. Symmetric with
|
|
430
|
+
// how the framework converts mcpl:channel-incoming / mcpl:push-event into
|
|
431
|
+
// user-role messages — we keep it module-side here so the gate stays a
|
|
432
|
+
// pure activation layer (no representation responsibility) and so the
|
|
433
|
+
// message format is colocated with the event source.
|
|
434
|
+
//
|
|
435
|
+
// Note: addMessages is applied unconditionally by the framework, but
|
|
436
|
+
// requestInference still passes through the EventGate. A gate-suppressed
|
|
437
|
+
// event will leave the message in context for the next inference to see —
|
|
438
|
+
// which is exactly what you want for the "burst landed during streaming"
|
|
439
|
+
// case.
|
|
440
|
+
const paths = ((event as { paths?: string[] }).paths ?? []).filter(p => typeof p === 'string');
|
|
441
|
+
const conflicts = (event as { conflicts?: string[] }).conflicts;
|
|
442
|
+
if (paths.length === 0) return { requestInference: true };
|
|
443
|
+
|
|
444
|
+
let text: string;
|
|
445
|
+
if (paths.length === 1) {
|
|
446
|
+
text = `[workspace event · ${op} · ${paths[0]}]`;
|
|
447
|
+
} else {
|
|
448
|
+
const list = paths.map(p => `- ${p}`).join('\n');
|
|
449
|
+
text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
450
|
+
}
|
|
451
|
+
if (conflicts && conflicts.length > 0) {
|
|
452
|
+
text += `\n[conflicts: ${conflicts.join(', ')}]`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
requestInference: true,
|
|
457
|
+
addMessages: [
|
|
458
|
+
{
|
|
459
|
+
participant: 'user',
|
|
460
|
+
content: [{ type: 'text', text }],
|
|
461
|
+
metadata: {
|
|
462
|
+
source: 'workspace',
|
|
463
|
+
mount: mountName,
|
|
464
|
+
op,
|
|
465
|
+
paths,
|
|
466
|
+
triggered: true,
|
|
467
|
+
...(conflicts && conflicts.length > 0 ? { conflicts } : {}),
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
};
|
|
403
472
|
}
|
|
404
473
|
|
|
405
474
|
// ==========================================================================
|
|
406
475
|
// Path Resolution
|
|
407
476
|
// ==========================================================================
|
|
408
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
|
|
480
|
+
* absolute filesystem path. Returns null if the mount is unknown or the
|
|
481
|
+
* resolved path escapes the mount root (path-traversal guard). Public API
|
|
482
|
+
* for peer modules that need to read workspace files directly.
|
|
483
|
+
*/
|
|
484
|
+
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
485
|
+
try {
|
|
486
|
+
const { mount, relativePath } = this.parsePath(mountPrefixedPath);
|
|
487
|
+
return resolve(mount.config.path, relativePath);
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
409
493
|
/**
|
|
410
494
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
411
495
|
*/
|
|
@@ -94,6 +94,15 @@ export interface MountState {
|
|
|
94
94
|
lastMaterializedBranchId: string | null;
|
|
95
95
|
/** Per-file hash at time of last materialization — baseline for conflict detection */
|
|
96
96
|
materializedHashes: Map<string, string>;
|
|
97
|
+
/**
|
|
98
|
+
* Wall-clock time chokidar emitted `ready` for this mount, or null if the
|
|
99
|
+
* watcher hasn't finished its initial scan. null after session start =
|
|
100
|
+
* watcher attach silently failed; a set value with empty tree = attached
|
|
101
|
+
* to an empty directory.
|
|
102
|
+
*/
|
|
103
|
+
watcherReadyAt: number | null;
|
|
104
|
+
/** Most recent chokidar error for this mount, if any. */
|
|
105
|
+
watcherError: string | null;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
/**
|
|
@@ -104,6 +113,8 @@ export interface WorkspaceModuleState {
|
|
|
104
113
|
mounts: Record<string, {
|
|
105
114
|
lastMaterializedSeq: number;
|
|
106
115
|
lastMaterializedBranchId?: string;
|
|
116
|
+
watcherReadyAt?: number | null;
|
|
117
|
+
watcherError?: string | null;
|
|
107
118
|
}>;
|
|
108
119
|
/** Branch ID considered "active" for materialization */
|
|
109
120
|
activeBranchId?: string;
|