@animalabs/agent-framework 0.7.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +10 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +33 -3
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +23 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +40 -4
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +15 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +156 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +27 -2
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +234 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1818 -188
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +11 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +66 -0
- package/dist/src/gate/event-gate.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 +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +115 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
- package/dist/src/mcpl/capability-grant.js +291 -0
- package/dist/src/mcpl/capability-grant.js.map +1 -0
- package/dist/src/mcpl/capability-mask.d.ts +51 -0
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
- package/dist/src/mcpl/capability-mask.js +190 -0
- package/dist/src/mcpl/capability-mask.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +30 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +216 -43
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +28 -8
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/errors.d.ts +6 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js +6 -0
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +45 -4
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +58 -67
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +1 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +16 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +20 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -9
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +231 -32
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +4 -3
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -0
- package/dist/src/mcpl/tags.js +52 -0
- package/dist/src/mcpl/tags.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +1 -1
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +35 -7
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +169 -64
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +16 -2
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +12 -0
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +26 -0
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +20 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +48 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +4 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +1 -1
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/refusal-reactions.d.ts +27 -0
- package/dist/src/refusal-reactions.d.ts.map +1 -0
- package/dist/src/refusal-reactions.js +35 -0
- package/dist/src/refusal-reactions.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +10 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -5
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +35 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +26 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +22 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +24 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +1 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/channel-registry-routing.test.js +38 -10
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channel-registry-typing.test.js +10 -1
- package/dist/test/channel-registry-typing.test.js.map +1 -1
- package/dist/test/checkpoint-attribution.test.js +168 -8
- package/dist/test/checkpoint-attribution.test.js.map +1 -1
- package/dist/test/code-execution.test.js +327 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
- package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
- package/dist/test/deferred-wake-integrity.test.js +320 -0
- package/dist/test/deferred-wake-integrity.test.js.map +1 -0
- package/dist/test/event-gate-selfwake.test.d.ts +15 -0
- package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
- package/dist/test/event-gate-selfwake.test.js +125 -0
- package/dist/test/event-gate-selfwake.test.js.map +1 -0
- package/dist/test/machine-close-provenance.test.d.ts +2 -0
- package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
- package/dist/test/machine-close-provenance.test.js +116 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -0
- package/dist/test/mcpl-access-provider.test.d.ts +2 -0
- package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
- package/dist/test/mcpl-access-provider.test.js +20 -0
- package/dist/test/mcpl-access-provider.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +10 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
- package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +109 -0
- package/dist/test/mcpl-capability-grant.test.js.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.js +266 -0
- package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-list-status.test.d.ts +2 -0
- package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
- package/dist/test/mcpl-list-status.test.js +78 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +177 -0
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.js +27 -2
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +1 -1
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/module-context-integration.test.d.ts +18 -0
- package/dist/test/module-context-integration.test.d.ts.map +1 -0
- package/dist/test/module-context-integration.test.js +181 -0
- package/dist/test/module-context-integration.test.js.map +1 -0
- package/dist/test/physical-window-restart.test.d.ts +14 -0
- package/dist/test/physical-window-restart.test.d.ts.map +1 -0
- package/dist/test/physical-window-restart.test.js +222 -0
- package/dist/test/physical-window-restart.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +270 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
- package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +74 -0
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
- package/dist/test/state-scaling-e2e.test.js +7 -1
- package/dist/test/state-scaling-e2e.test.js.map +1 -1
- package/dist/test/state-scaling.test.js +9 -1
- package/dist/test/state-scaling.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.d.ts +13 -0
- package/dist/test/tool-result-spill.test.d.ts.map +1 -0
- package/dist/test/tool-result-spill.test.js +463 -0
- package/dist/test/tool-result-spill.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +45 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/utils-surface.test.d.ts +2 -0
- package/dist/test/utils-surface.test.d.ts.map +1 -0
- package/dist/test/utils-surface.test.js +145 -0
- package/dist/test/utils-surface.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +71 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.d.ts +16 -0
- package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
- package/dist/test/workspace-restore-order.test.js +140 -0
- package/dist/test/workspace-restore-order.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/agent.ts +37 -3
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +56 -4
- package/src/code-execution/runtime-py.ts +156 -7
- package/src/code-execution/tool-definition.ts +27 -2
- package/src/framework.ts +2080 -203
- package/src/gate/event-gate.ts +72 -0
- package/src/index.ts +1 -0
- package/src/mcpl/capability-grant.ts +319 -0
- package/src/mcpl/capability-mask.ts +228 -0
- package/src/mcpl/channel-registry.ts +265 -43
- package/src/mcpl/checkpoint-manager.ts +28 -8
- package/src/mcpl/errors.ts +7 -0
- package/src/mcpl/feature-set-manager.ts +50 -5
- package/src/mcpl/hook-orchestrator.ts +64 -86
- package/src/mcpl/index.ts +0 -2
- package/src/mcpl/prose-stream-router.ts +17 -0
- package/src/mcpl/push-handler.ts +18 -2
- package/src/mcpl/server-connection.ts +262 -34
- package/src/mcpl/server-registry.ts +4 -3
- package/src/mcpl/tags.ts +50 -0
- package/src/mcpl/transport.ts +37 -7
- package/src/mcpl/types.ts +187 -78
- package/src/module-registry.ts +34 -0
- package/src/modules/workspace/index.ts +49 -15
- package/src/modules/workspace/sync.ts +1 -1
- package/src/refusal-reactions.ts +37 -0
- package/src/tool-result-history.ts +17 -5
- package/src/types/agent.ts +36 -0
- package/src/types/framework.ts +27 -0
- package/src/types/module.ts +24 -0
- package/src/types/trace.ts +22 -0
|
@@ -34,6 +34,8 @@ import type {
|
|
|
34
34
|
import type { McplServerRegistry } from './server-registry.js';
|
|
35
35
|
import type { FeatureSetManager } from './feature-set-manager.js';
|
|
36
36
|
import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
|
|
37
|
+
import { expandCoreTags } from './tags.js';
|
|
38
|
+
import { CapabilityGrant } from './capability-grant.js';
|
|
37
39
|
|
|
38
40
|
// ============================================================================
|
|
39
41
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
@@ -253,7 +255,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
253
255
|
'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
|
|
254
256
|
'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
|
|
255
257
|
'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
|
|
256
|
-
'and is NOT posted. To reply instead, just write plain text (no tool call).'
|
|
258
|
+
'and is NOT posted. To reply instead, just write plain text (no tool call). ' +
|
|
259
|
+
'To end this turn but come back on your own shortly, set wake_in_seconds.',
|
|
257
260
|
inputSchema: {
|
|
258
261
|
type: 'object' as const,
|
|
259
262
|
properties: {
|
|
@@ -261,6 +264,13 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
261
264
|
type: 'string',
|
|
262
265
|
description: 'Optional private note on why you are not replying (not sent anywhere).',
|
|
263
266
|
},
|
|
267
|
+
wake_in_seconds: {
|
|
268
|
+
type: 'number',
|
|
269
|
+
description:
|
|
270
|
+
'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
|
|
271
|
+
'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
|
|
272
|
+
'wake before then cancels it. Omit to stay idle until the next external wake.',
|
|
273
|
+
},
|
|
264
274
|
},
|
|
265
275
|
required: [],
|
|
266
276
|
},
|
|
@@ -337,6 +347,21 @@ interface ChannelRegistryOptions {
|
|
|
337
347
|
// ChannelRegistry
|
|
338
348
|
// ============================================================================
|
|
339
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Who is actually making a channel tool call, as established by the
|
|
352
|
+
* framework's own dispatch — model-origin calls come through
|
|
353
|
+
* dispatchToolCall (which knows the agent), module calls come through
|
|
354
|
+
* ModuleContext.callTool. Tool INPUT can claim anything; this cannot.
|
|
355
|
+
*/
|
|
356
|
+
export type ChannelToolOrigin =
|
|
357
|
+
| { kind: 'module'; moduleName?: string }
|
|
358
|
+
| { kind: 'agent'; agentName: string };
|
|
359
|
+
|
|
360
|
+
/** The machine decision sources a module-origin channel_close may record.
|
|
361
|
+
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
362
|
+
* not arbitrary self-description. */
|
|
363
|
+
export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
|
|
364
|
+
|
|
340
365
|
export class ChannelRegistry {
|
|
341
366
|
private serverRegistry: McplServerRegistry;
|
|
342
367
|
private featureSetManager: FeatureSetManager;
|
|
@@ -443,8 +468,19 @@ export class ChannelRegistry {
|
|
|
443
468
|
responder?: Responder,
|
|
444
469
|
): Promise<void> {
|
|
445
470
|
const registeredIds: string[] = [];
|
|
471
|
+
// §14.5: per-descriptor authorization with ITEMIZED results — one entry
|
|
472
|
+
// per submitted descriptor. Strict 0.5 servers treat a result without
|
|
473
|
+
// `results` (or a descriptor missing from it) as rejected, so the shape
|
|
474
|
+
// is interop-critical, not decorative. Rejection here is per-descriptor
|
|
475
|
+
// validation; the method-level channels.register gate already ran at the
|
|
476
|
+
// connection (§14.1).
|
|
477
|
+
const results: ChannelsRegisterResult['results'] = [];
|
|
446
478
|
|
|
447
479
|
for (const channel of params.channels) {
|
|
480
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
481
|
+
results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
448
484
|
const key = `${serverId}:${channel.id}`;
|
|
449
485
|
this.channels.set(key, {
|
|
450
486
|
serverId,
|
|
@@ -452,11 +488,12 @@ export class ChannelRegistry {
|
|
|
452
488
|
open: false,
|
|
453
489
|
});
|
|
454
490
|
registeredIds.push(channel.id);
|
|
491
|
+
results.push({ id: channel.id, accepted: true });
|
|
455
492
|
}
|
|
456
493
|
|
|
457
494
|
// Respond before reconciliation — the server blocks on this response and
|
|
458
495
|
// can't process channels/open until it arrives.
|
|
459
|
-
const result: ChannelsRegisterResult = { registered: registeredIds };
|
|
496
|
+
const result: ChannelsRegisterResult = { registered: registeredIds, results };
|
|
460
497
|
responder?.respond(result);
|
|
461
498
|
|
|
462
499
|
// One-time migration from the retired recipe policy, then reconcile the
|
|
@@ -481,39 +518,69 @@ export class ChannelRegistry {
|
|
|
481
518
|
async handleChanged(
|
|
482
519
|
serverId: string,
|
|
483
520
|
params: ChannelsChangedParams,
|
|
521
|
+
responder?: Responder,
|
|
484
522
|
): Promise<void> {
|
|
485
|
-
//
|
|
523
|
+
// §14.5: dual-mode. Request form answers ITEMIZED per-descriptor
|
|
524
|
+
// results for added channels; Notification form filters itemwise with a
|
|
525
|
+
// diagnostic. Either way, added descriptors are validated exactly like
|
|
526
|
+
// channels/register — a changed-set cannot smuggle in what register
|
|
527
|
+
// would have rejected (PR #79 review blocker 5).
|
|
528
|
+
const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
|
|
529
|
+
// Process removed channels — itemized truthfully: removal of a channel
|
|
530
|
+
// we never had is reported, not silently accepted.
|
|
486
531
|
if (params.removed) {
|
|
487
532
|
for (const channelId of params.removed) {
|
|
488
533
|
const key = `${serverId}:${channelId}`;
|
|
489
|
-
this.channels.delete(key);
|
|
534
|
+
const existed = this.channels.delete(key);
|
|
490
535
|
this.stopTyping(channelId);
|
|
536
|
+
addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
|
|
491
537
|
}
|
|
492
538
|
}
|
|
493
539
|
|
|
494
|
-
// Process updated channels
|
|
540
|
+
// Process updated channels — validated itemwise exactly like added
|
|
541
|
+
// (§14.5: a changed-set cannot smuggle in what register would reject),
|
|
542
|
+
// and every submitted descriptor gets a verdict: a strict server reads
|
|
543
|
+
// absence from `results` as rejection.
|
|
495
544
|
if (params.updated) {
|
|
496
545
|
for (const channel of params.updated) {
|
|
546
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
547
|
+
addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
548
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
497
551
|
const key = `${serverId}:${channel.id}`;
|
|
498
552
|
const existing = this.channels.get(key);
|
|
499
|
-
if (existing) {
|
|
500
|
-
|
|
553
|
+
if (!existing) {
|
|
554
|
+
addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
|
|
555
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
|
|
556
|
+
continue;
|
|
501
557
|
}
|
|
558
|
+
existing.descriptor = channel;
|
|
559
|
+
addedResults.push({ id: channel.id, accepted: true });
|
|
502
560
|
}
|
|
503
561
|
}
|
|
504
562
|
|
|
505
|
-
// Process added channels (register
|
|
563
|
+
// Process added channels (validate per-descriptor, register, reconcile)
|
|
564
|
+
const accepted: typeof params.added = [];
|
|
506
565
|
if (params.added) {
|
|
507
566
|
for (const channel of params.added) {
|
|
567
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
568
|
+
addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
569
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
508
572
|
const key = `${serverId}:${channel.id}`;
|
|
509
573
|
this.channels.set(key, {
|
|
510
574
|
serverId,
|
|
511
575
|
descriptor: channel,
|
|
512
576
|
open: false,
|
|
513
577
|
});
|
|
578
|
+
addedResults.push({ id: channel.id, accepted: true });
|
|
579
|
+
accepted.push(channel);
|
|
514
580
|
}
|
|
515
|
-
await this.reconcileChannels(serverId,
|
|
581
|
+
if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
|
|
516
582
|
}
|
|
583
|
+
responder?.respond({ results: addedResults });
|
|
517
584
|
|
|
518
585
|
this.emitTraceFn({
|
|
519
586
|
type: 'mcpl:channels-changed',
|
|
@@ -538,13 +605,11 @@ export class ChannelRegistry {
|
|
|
538
605
|
const results: ChannelIncomingMessageResult[] = [];
|
|
539
606
|
|
|
540
607
|
for (const message of params.messages) {
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
this.defaultPublishMessageId = message.messageId;
|
|
547
|
-
this.defaultPublishThreadId = message.threadId;
|
|
608
|
+
// §14.5 FIRST, before ANY semantic processing: admission against the
|
|
609
|
+
// actually-registered channel precedes tag expansion and content
|
|
610
|
+
// conversion — decoding an unregistered sender's payload (including
|
|
611
|
+
// inline base64) is allocation and parsing work done for a message
|
|
612
|
+
// that must be rejected (Sol, PR #79 re-review blocker 2).
|
|
548
613
|
|
|
549
614
|
// Lazy-register the channel if we've never seen it. A channel can deliver
|
|
550
615
|
// an incoming message before its channels/register (boot enumeration) or
|
|
@@ -557,31 +622,44 @@ export class ChannelRegistry {
|
|
|
557
622
|
// is reachable. The inbound message carries enough to make it publishable,
|
|
558
623
|
// so register it here; a later authoritative channels/register or
|
|
559
624
|
// channels/changed will overwrite this descriptor with the richer one.
|
|
625
|
+
// §14.5: channels/incoming is validated against the ACTUALLY
|
|
626
|
+
// REGISTERED channel. An unknown claimed channelId is rejected
|
|
627
|
+
// per-message with an itemized result — never minted by its first
|
|
628
|
+
// message (PR #79 review blocker 5: lazy-registration let a server
|
|
629
|
+
// self-attest a channel identity without ever passing
|
|
630
|
+
// channels/register authorization). A server whose channel genuinely
|
|
631
|
+
// exists registers it first; discord's DM case goes through
|
|
632
|
+
// ensureChannelRegistered on push/event, which creates a CLOSED
|
|
633
|
+
// routable entry rather than an open self-attested one.
|
|
560
634
|
const incomingKey = `${serverId}:${message.channelId}`;
|
|
561
635
|
if (!this.channels.has(incomingKey)) {
|
|
562
|
-
const channelLabel =
|
|
563
|
-
typeof message.metadata?.channelName === 'string' && message.metadata.channelName
|
|
564
|
-
? (message.metadata.channelName as string)
|
|
565
|
-
: message.channelId;
|
|
566
|
-
this.channels.set(incomingKey, {
|
|
567
|
-
serverId,
|
|
568
|
-
descriptor: {
|
|
569
|
-
id: message.channelId,
|
|
570
|
-
type: serverId,
|
|
571
|
-
label: channelLabel,
|
|
572
|
-
direction: 'bidirectional',
|
|
573
|
-
address: message.threadId ? { threadId: message.threadId } : undefined,
|
|
574
|
-
metadata: { lazyRegistered: true },
|
|
575
|
-
},
|
|
576
|
-
open: true,
|
|
577
|
-
});
|
|
578
636
|
this.emitTraceFn({
|
|
579
|
-
type: 'mcpl:channel-
|
|
637
|
+
type: 'mcpl:channel-incoming-rejected',
|
|
580
638
|
serverId,
|
|
581
639
|
channelId: message.channelId,
|
|
582
|
-
|
|
640
|
+
reason: 'unknown channel — not registered (§14.5)',
|
|
583
641
|
});
|
|
584
|
-
|
|
642
|
+
results.push({
|
|
643
|
+
messageId: message.messageId,
|
|
644
|
+
accepted: false,
|
|
645
|
+
reason: `unknown channel "${message.channelId}" — register it first (§14.5)`,
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ACCEPTED from here down: semantic processing only for admitted
|
|
651
|
+
// messages. §16.3 core-tag closure, then content conversion.
|
|
652
|
+
if (message.tags) message.tags = expandCoreTags(message.tags);
|
|
653
|
+
const convertedContent: ContentBlock[] = message.content.map(convertBlock);
|
|
654
|
+
|
|
655
|
+
// Track default publish channel (most recent ACCEPTED incoming) —
|
|
656
|
+
// deliberately after §14.5 validation: a rejected message from an
|
|
657
|
+
// unregistered channel must not retarget outbound speech (the locus is
|
|
658
|
+
// exactly the authority a self-attested channel would be stealing).
|
|
659
|
+
this.defaultPublishChannel = message.channelId;
|
|
660
|
+
this.defaultPublishMessageId = message.messageId;
|
|
661
|
+
this.defaultPublishThreadId = message.threadId;
|
|
662
|
+
{
|
|
585
663
|
// A server sending channels/incoming is authoritative evidence that
|
|
586
664
|
// the transport is actually open. This repairs transient status only;
|
|
587
665
|
// durable desired state still changes exclusively through lifecycle
|
|
@@ -855,8 +933,21 @@ export class ChannelRegistry {
|
|
|
855
933
|
|
|
856
934
|
/**
|
|
857
935
|
* Handle a call to one of the synthesized channel tools.
|
|
936
|
+
*
|
|
937
|
+
* `origin` is TRUSTED DISPATCH CONTEXT, supplied by the framework's own
|
|
938
|
+
* routing (dispatchChannelToolCall and the public executeToolCall for
|
|
939
|
+
* model-origin calls, the framework's private ModuleRegistry closure for
|
|
940
|
+
* module ctx.callTool) — never derived from tool input. Machine
|
|
941
|
+
* provenance on channel_close is honored only for module origin; a
|
|
942
|
+
* model-origin call carrying the same fields is recorded as the agent
|
|
943
|
+
* decision it actually is. Absent origin is treated as agent-origin (the
|
|
944
|
+
* untrusted-safe default).
|
|
858
945
|
*/
|
|
859
|
-
async handleChannelToolCall(
|
|
946
|
+
async handleChannelToolCall(
|
|
947
|
+
toolName: string,
|
|
948
|
+
input: unknown,
|
|
949
|
+
origin?: ChannelToolOrigin,
|
|
950
|
+
): Promise<ToolResult> {
|
|
860
951
|
switch (toolName) {
|
|
861
952
|
case 'channel_list':
|
|
862
953
|
return this.handleToolList();
|
|
@@ -870,7 +961,15 @@ export class ChannelRegistry {
|
|
|
870
961
|
});
|
|
871
962
|
|
|
872
963
|
case 'channel_close':
|
|
873
|
-
return this.handleToolClose(
|
|
964
|
+
return this.handleToolClose(
|
|
965
|
+
input as {
|
|
966
|
+
channelId: string;
|
|
967
|
+
serverId?: string;
|
|
968
|
+
source?: string;
|
|
969
|
+
overrideExplicitOpen?: boolean;
|
|
970
|
+
},
|
|
971
|
+
origin,
|
|
972
|
+
);
|
|
874
973
|
|
|
875
974
|
case 'channel_decline':
|
|
876
975
|
return this.handleToolDecline(input as {
|
|
@@ -887,7 +986,7 @@ export class ChannelRegistry {
|
|
|
887
986
|
return this.handleToolThink(input as { content?: string });
|
|
888
987
|
|
|
889
988
|
case 'skip_reply':
|
|
890
|
-
return this.handleToolSkipReply(input as { reason?: string });
|
|
989
|
+
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
|
|
891
990
|
|
|
892
991
|
default:
|
|
893
992
|
return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
|
|
@@ -1145,6 +1244,19 @@ export class ChannelRegistry {
|
|
|
1145
1244
|
const server = this.serverRegistry.getServer(serverId);
|
|
1146
1245
|
if (!server) return;
|
|
1147
1246
|
|
|
1247
|
+
// §14.1: channels/open + channels/close require channels.lifecycle.
|
|
1248
|
+
// Skipping reconciliation for an ungranted server is the enforcement —
|
|
1249
|
+
// its channels simply stay in whatever state the server chose, and the
|
|
1250
|
+
// host never directs lifecycle it was not granted authority over.
|
|
1251
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1252
|
+
this.emitTraceFn({
|
|
1253
|
+
type: 'mcpl:channel-reconcile-skipped',
|
|
1254
|
+
serverId,
|
|
1255
|
+
reason: 'channels.lifecycle not in effective grant (§14.1)',
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1148
1260
|
// Channels the subscription policy freshly admitted in THIS pass —
|
|
1149
1261
|
// announced to the agent as one batched notice after the loop, so a
|
|
1150
1262
|
// first boot on a new policy doesn't produce one notice per channel.
|
|
@@ -1219,6 +1331,10 @@ export class ChannelRegistry {
|
|
|
1219
1331
|
* (typing timer lifecycle is still managed for when the callback is wired).
|
|
1220
1332
|
*/
|
|
1221
1333
|
private sendTypingNotification(serverId: string, channelId: string): void {
|
|
1334
|
+
// §14.1: channels/typing requires channels.typing in the grant. Silent
|
|
1335
|
+
// skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
|
|
1336
|
+
// tick would be noise.
|
|
1337
|
+
if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
|
|
1222
1338
|
if (this.sendTypingFn) {
|
|
1223
1339
|
this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
|
|
1224
1340
|
}
|
|
@@ -1230,6 +1346,13 @@ export class ChannelRegistry {
|
|
|
1230
1346
|
// Private: Channel Lookup
|
|
1231
1347
|
// ==========================================================================
|
|
1232
1348
|
|
|
1349
|
+
/** Descriptors registered by one server — the §14.3 channels/list answer. */
|
|
1350
|
+
descriptorsForServer(serverId: string): ChannelDescriptor[] {
|
|
1351
|
+
return [...this.channels.values()]
|
|
1352
|
+
.filter((e) => e.serverId === serverId)
|
|
1353
|
+
.map((e) => e.descriptor);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1233
1356
|
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
1234
1357
|
getChannelServerId(channelId: string): string | null {
|
|
1235
1358
|
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
@@ -1334,6 +1457,11 @@ export class ChannelRegistry {
|
|
|
1334
1457
|
if (!server) {
|
|
1335
1458
|
throw new Error(`Server not found: ${entry.serverId}`);
|
|
1336
1459
|
}
|
|
1460
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1461
|
+
// Desired state is already recorded — intent sticks; reconciliation
|
|
1462
|
+
// will retry if the grant later widens (§6.7 expansion-on-receipt).
|
|
1463
|
+
throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
|
|
1464
|
+
}
|
|
1337
1465
|
const result: ChannelsOpenResult = await server.sendChannelsOpen({
|
|
1338
1466
|
channelId: entry.descriptor.id,
|
|
1339
1467
|
type: entry.descriptor.type,
|
|
@@ -1588,7 +1716,25 @@ export class ChannelRegistry {
|
|
|
1588
1716
|
}
|
|
1589
1717
|
}
|
|
1590
1718
|
|
|
1591
|
-
private async handleToolClose(
|
|
1719
|
+
private async handleToolClose(
|
|
1720
|
+
input: {
|
|
1721
|
+
channelId: string;
|
|
1722
|
+
serverId?: string;
|
|
1723
|
+
/** Machine callers name their decision source ('subscription-gc',
|
|
1724
|
+
* 'housekeeping') so the durable record carries honest provenance.
|
|
1725
|
+
* Honored ONLY for module-origin dispatch — tool input is untrusted,
|
|
1726
|
+
* so a model-origin call carrying this field is still recorded as
|
|
1727
|
+
* 'agent-tool' (the resident may close their own channel; they may
|
|
1728
|
+
* not attribute the act to housekeeping). */
|
|
1729
|
+
source?: string;
|
|
1730
|
+
/** A machine close aimed at an explicitly-opened channel is refused
|
|
1731
|
+
* unless the caller certifies an explicit idle lease (a configured
|
|
1732
|
+
* per-channel budget is consent to close at that budget). Module
|
|
1733
|
+
* origin only, like `source`. */
|
|
1734
|
+
overrideExplicitOpen?: boolean;
|
|
1735
|
+
},
|
|
1736
|
+
origin?: ChannelToolOrigin,
|
|
1737
|
+
): Promise<ToolResult> {
|
|
1592
1738
|
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1593
1739
|
const entry = resolved.entry;
|
|
1594
1740
|
if (!entry) {
|
|
@@ -1607,7 +1753,47 @@ export class ChannelRegistry {
|
|
|
1607
1753
|
};
|
|
1608
1754
|
}
|
|
1609
1755
|
|
|
1610
|
-
|
|
1756
|
+
// Provenance comes from trusted dispatch, not from self-description:
|
|
1757
|
+
// only module-origin calls may record a machine source, and only from
|
|
1758
|
+
// the closed vocabulary. Agent-origin (or origin-less — the safe
|
|
1759
|
+
// default for any legacy caller) records 'agent-tool' and has its
|
|
1760
|
+
// machine fields ignored entirely.
|
|
1761
|
+
const isModuleOrigin = origin?.kind === 'module';
|
|
1762
|
+
let closeSource = 'agent-tool';
|
|
1763
|
+
if (isModuleOrigin && input.source !== undefined) {
|
|
1764
|
+
if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
|
|
1765
|
+
return {
|
|
1766
|
+
success: false,
|
|
1767
|
+
isError: true,
|
|
1768
|
+
error:
|
|
1769
|
+
`Unknown machine close source '${input.source}'. Machine closes must use one of: ` +
|
|
1770
|
+
`${[...MACHINE_CLOSE_SOURCES].join(', ')}.`,
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
closeSource = input.source;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Housekeeping must not override stated intent (issue #5: GC closes wore
|
|
1777
|
+
// the agent's badge and reset explicitly-opened doors). A machine-sourced
|
|
1778
|
+
// close of a channel whose current desired state is an agent/operator
|
|
1779
|
+
// 'open' is refused — structurally, so the caller can stand down rather
|
|
1780
|
+
// than retry — unless it certifies an explicit idle lease.
|
|
1781
|
+
if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
|
|
1782
|
+
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
1783
|
+
if (current?.state === 'open' && current.source === 'agent-tool') {
|
|
1784
|
+
return {
|
|
1785
|
+
success: false,
|
|
1786
|
+
isError: false,
|
|
1787
|
+
error:
|
|
1788
|
+
`Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
|
|
1789
|
+
`a '${closeSource}' close does not override stated intent. Machine closes ` +
|
|
1790
|
+
`of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
|
|
1791
|
+
data: { refusal: 'explicit-open', channelId: input.channelId },
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
|
|
1611
1797
|
this.stopTyping(input.channelId);
|
|
1612
1798
|
|
|
1613
1799
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
@@ -1620,6 +1806,13 @@ export class ChannelRegistry {
|
|
|
1620
1806
|
}
|
|
1621
1807
|
|
|
1622
1808
|
try {
|
|
1809
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1810
|
+
return {
|
|
1811
|
+
success: false,
|
|
1812
|
+
error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
|
|
1813
|
+
isError: true,
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1623
1816
|
await server.sendChannelsClose({ channelId: input.channelId });
|
|
1624
1817
|
entry.open = false;
|
|
1625
1818
|
return {
|
|
@@ -1665,6 +1858,8 @@ export class ChannelRegistry {
|
|
|
1665
1858
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1666
1859
|
if (!server) {
|
|
1667
1860
|
acknowledgmentError = `Server not found: ${entry.serverId}`;
|
|
1861
|
+
} else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
|
|
1862
|
+
acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
|
|
1668
1863
|
} else {
|
|
1669
1864
|
try {
|
|
1670
1865
|
const result = await server.sendChannelsAcknowledge({
|
|
@@ -1733,7 +1928,14 @@ export class ChannelRegistry {
|
|
|
1733
1928
|
* tool, so any trailing prose this turn is NOT posted. Replaces the old
|
|
1734
1929
|
* overloaded use of `think` for staying silent.
|
|
1735
1930
|
*/
|
|
1736
|
-
private handleToolSkipReply(input: { reason?: string }): ToolResult {
|
|
1931
|
+
private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: number }): ToolResult {
|
|
1932
|
+
// The self-wake itself is armed by the framework (which owns the
|
|
1933
|
+
// EventGate) BEFORE this dispatch; when the gate is absent the framework
|
|
1934
|
+
// strips the field so this confirmation stays truthful.
|
|
1935
|
+
const wakeSecs = Number(input.wake_in_seconds);
|
|
1936
|
+
const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
|
|
1937
|
+
? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
|
|
1938
|
+
: undefined;
|
|
1737
1939
|
return {
|
|
1738
1940
|
success: true,
|
|
1739
1941
|
// The note says "ended the turn" — make it TRUE. Without endTurn the
|
|
@@ -1747,7 +1949,9 @@ export class ChannelRegistry {
|
|
|
1747
1949
|
// on Fable: ~60% of skip_reply result bytes were verbatim input echo).
|
|
1748
1950
|
data: {
|
|
1749
1951
|
skipped: true,
|
|
1750
|
-
note:
|
|
1952
|
+
note: selfWake !== undefined
|
|
1953
|
+
? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
|
|
1954
|
+
: 'Turn ended; nothing sent.',
|
|
1751
1955
|
},
|
|
1752
1956
|
};
|
|
1753
1957
|
}
|
|
@@ -1813,7 +2017,12 @@ export class ChannelRegistry {
|
|
|
1813
2017
|
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
1814
2018
|
if (matches.length !== 1) return null;
|
|
1815
2019
|
const server = this.serverRegistry.getServer(matches[0]!.serverId);
|
|
1816
|
-
|
|
2020
|
+
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
2021
|
+
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
2022
|
+
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
2023
|
+
// double-post, AUDIT-001), and pre-policy it would have sent before the
|
|
2024
|
+
// server was told anything was granted.
|
|
2025
|
+
if (!CapabilityGrant.of(server).has('channels.streaming')) return null;
|
|
1817
2026
|
return server;
|
|
1818
2027
|
}
|
|
1819
2028
|
|
|
@@ -1905,6 +2114,12 @@ export class ChannelRegistry {
|
|
|
1905
2114
|
if (!server) {
|
|
1906
2115
|
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
1907
2116
|
}
|
|
2117
|
+
// §14.1: channels/publish requires channels.publish in the grant. The
|
|
2118
|
+
// host not sending is the enforcement — a send to an ungranted server
|
|
2119
|
+
// would have it act on authority it was never told it has.
|
|
2120
|
+
if (!CapabilityGrant.of(server).has('channels.publish')) {
|
|
2121
|
+
return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`);
|
|
2122
|
+
}
|
|
1908
2123
|
|
|
1909
2124
|
const publishParams: ChannelsPublishParams = {
|
|
1910
2125
|
conversationId,
|
|
@@ -1981,6 +2196,13 @@ export class ChannelRegistry {
|
|
|
1981
2196
|
}
|
|
1982
2197
|
|
|
1983
2198
|
try {
|
|
2199
|
+
if (!CapabilityGrant.of(server).has('channels.publish')) {
|
|
2200
|
+
return {
|
|
2201
|
+
success: false,
|
|
2202
|
+
error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
|
|
2203
|
+
isError: true,
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
1984
2206
|
const publishParams: ChannelsPublishParams = {
|
|
1985
2207
|
conversationId: '', // Framework will fill this when wired
|
|
1986
2208
|
channelId,
|
|
@@ -355,17 +355,37 @@ export class CheckpointManager {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
358
|
+
* Resolve a server-managed stateful feature set for a server.
|
|
359
|
+
*
|
|
360
|
+
* An explicit feature set is returned only when it is registered and
|
|
361
|
+
* server-managed. Without one, the fallback is safe only when exactly one
|
|
362
|
+
* server-managed stateful set exists. Returns null for no match or ambiguity.
|
|
360
363
|
*/
|
|
361
|
-
getStatefulFeatureSet(serverId: string): string | null {
|
|
364
|
+
getStatefulFeatureSet(serverId: string, featureSet?: string): string | null {
|
|
362
365
|
const prefix = `${serverId}:`;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
|
|
367
|
+
if (featureSet !== undefined) {
|
|
368
|
+
const tree = this.trees.get(this.key(serverId, featureSet));
|
|
369
|
+
return tree && !tree.hostState ? featureSet : null;
|
|
367
370
|
}
|
|
368
|
-
|
|
371
|
+
|
|
372
|
+
let match: string | null = null;
|
|
373
|
+
for (const [key, tree] of this.trees) {
|
|
374
|
+
if (!key.startsWith(prefix) || tree.hostState) continue;
|
|
375
|
+
if (match !== null) return null;
|
|
376
|
+
match = key.slice(prefix.length);
|
|
377
|
+
}
|
|
378
|
+
return match;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether an untagged tool cannot safely select a server-managed set. */
|
|
382
|
+
hasAmbiguousServerManagedFeatureSets(serverId: string): boolean {
|
|
383
|
+
const prefix = `${serverId}:`;
|
|
384
|
+
let count = 0;
|
|
385
|
+
for (const [key, tree] of this.trees) {
|
|
386
|
+
if (key.startsWith(prefix) && !tree.hostState && ++count > 1) return true;
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
369
389
|
}
|
|
370
390
|
|
|
371
391
|
/**
|
package/src/mcpl/errors.ts
CHANGED
|
@@ -14,6 +14,13 @@ import type { JsonRpcError } from './types.js';
|
|
|
14
14
|
/** Message used a disabled feature set. */
|
|
15
15
|
export const FEATURE_SET_NOT_ENABLED = -32001;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Method requires a capability the host has masked for this server
|
|
19
|
+
* (McplServerConfig.enabledCapabilities/disabledCapabilities). Host-side
|
|
20
|
+
* extension code, pending a spec assignment.
|
|
21
|
+
*/
|
|
22
|
+
export const CAPABILITY_DISABLED = -32002;
|
|
23
|
+
|
|
17
24
|
/** Message used an undeclared feature set. */
|
|
18
25
|
export const UNKNOWN_FEATURE_SET = -32003;
|
|
19
26
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
FEATURE_SET_NOT_ENABLED,
|
|
19
19
|
UNKNOWN_FEATURE_SET,
|
|
20
20
|
} from './errors.js';
|
|
21
|
+
import { isKnownCapabilityPath } from './capability-grant.js';
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
23
24
|
// Error Class
|
|
@@ -143,7 +144,16 @@ export class FeatureSetManager {
|
|
|
143
144
|
initializeServer(
|
|
144
145
|
serverId: string,
|
|
145
146
|
capabilities: McplCapabilities,
|
|
146
|
-
config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
|
|
147
|
+
config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] },
|
|
148
|
+
/**
|
|
149
|
+
* The effective capability grant (§5.4). When provided, §6.4's
|
|
150
|
+
* FAIL-CLOSED derivation runs: a feature set whose `uses` is absent,
|
|
151
|
+
* empty, or contains an unrecognized value is disabled with reason
|
|
152
|
+
* `invalid_uses`; one whose `uses` names an ungranted capability is
|
|
153
|
+
* disabled with the missing paths recorded. Derivation overrides config
|
|
154
|
+
* enablement — config can narrow the derivable set, never widen past it.
|
|
155
|
+
*/
|
|
156
|
+
grant?: import('./capability-grant.js').CapabilityGrant,
|
|
147
157
|
): FeatureSetsUpdateParams {
|
|
148
158
|
// Cross-package compat: agent-framework's McplCapabilities types
|
|
149
159
|
// featureSets as Record<string, FeatureSetDeclaration>, but mcpl-core-ts
|
|
@@ -165,11 +175,16 @@ export class FeatureSetManager {
|
|
|
165
175
|
enabled: new Set<string>(),
|
|
166
176
|
};
|
|
167
177
|
|
|
168
|
-
//
|
|
169
|
-
|
|
178
|
+
// §5.3 as pinned (mcpl e869744): ABSENT enabledFeatureSets constrains
|
|
179
|
+
// nothing — every declared set is a candidate and §6.4 derivation
|
|
180
|
+
// governs. PRESENT is an allowlist ([] = deny-all selection). The old
|
|
181
|
+
// reading (unmentioned defaults disabled) reversed the pin (PR #79
|
|
182
|
+
// review blocker 7). FLEET BEHAVIOR CHANGE: configs that omitted
|
|
183
|
+
// enabledFeatureSets now enable every derivable declared set.
|
|
170
184
|
const disablePatterns = config?.disabledFeatureSets ?? [];
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
const toEnable = config?.enabledFeatureSets === undefined
|
|
186
|
+
? Object.keys(declared)
|
|
187
|
+
: resolvePatterns(config.enabledFeatureSets, declared);
|
|
173
188
|
const toDisable = resolvePatterns(disablePatterns, declared);
|
|
174
189
|
|
|
175
190
|
// Enable matching sets
|
|
@@ -182,6 +197,36 @@ export class FeatureSetManager {
|
|
|
182
197
|
state.enabled.delete(name);
|
|
183
198
|
}
|
|
184
199
|
|
|
200
|
+
// §6.4 fail-closed derivation against the grant. Runs AFTER config
|
|
201
|
+
// resolution so it can only remove: nothing a declaration or a config
|
|
202
|
+
// says can supply a capability the grant lacks.
|
|
203
|
+
if (grant) {
|
|
204
|
+
for (const [name, decl] of Object.entries(declared)) {
|
|
205
|
+
const uses = (decl as { uses?: unknown }).uses;
|
|
206
|
+
if (!Array.isArray(uses) || uses.length === 0) {
|
|
207
|
+
if (state.enabled.delete(name) || true) {
|
|
208
|
+
console.error(`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — uses absent or empty)`);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const unknown = uses.filter((u) => typeof u !== 'string' || !isKnownCapabilityPath(u));
|
|
213
|
+
if (unknown.length > 0) {
|
|
214
|
+
state.enabled.delete(name);
|
|
215
|
+
console.error(
|
|
216
|
+
`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`,
|
|
217
|
+
);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const missing = (uses as string[]).filter((u) => !grant.has(u));
|
|
221
|
+
if (missing.length > 0) {
|
|
222
|
+
state.enabled.delete(name);
|
|
223
|
+
console.error(
|
|
224
|
+
`[mcpl] ${serverId}/${name} disabled: missing capabilities ${missing.join(', ')} (§6.4)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
185
230
|
this.servers.set(serverId, state);
|
|
186
231
|
|
|
187
232
|
// Build the update params to send to the server
|