Repository navigation
A channel a server opens in another's place isn't closed again when the host has no decision recorded for it #730
Description
When a server answers channels/open for a different channel than the one the host asked for, the host closes the channel the server opened instead, through that channel's own lifecycle. That close is skipped when the host has no decision recorded for the channel, as for one first registered from a push, such as a DM. The server's substitute then stays open, and the host never asks it to stop.
The close is closeSubstitutedChannel (src/mcpl/channel-registry.ts:3633 at main 9fdc3a65). It acts only when getDesiredState for the substitute is 'closed' (:3635). A channel registered from a push (ensureChannelRegistered) has no decision recorded, so getDesiredState is undefined for it, and the close is skipped.
A run at main 9fdc3a65 (Linux, Node 24.22.1) used a ChannelRegistry and a server stub that answers every channels/open for channel y. The agent called channel_open for x:
yregistered bychannels/changed, withinitiallyOpen: false: the decision forywas"closed". The registry traced the substitution and sentchannels/open x, thenchannels/close y.yregistered from a push: the decision forywasundefined. The registry traced the substitution and sentchannels/open xonly. Nothing asked the server to closey.
In both cases, a channels/incoming message for y then reached the agent as a mcpl:channel-incoming event. The probe hands the registry that message directly, whatever the host asked of the server, so the run shows only the missing close. By our reading of the close's own comment, that close is how the host stops a substitute's traffic from reaching the agent, so with a pushed channel the traffic goes on.
A shape to weigh: a substitute is closed unless the host's decision for it is open, so a channel with no decision on record is closed as a 'closed' one is.
Our internal review of #568, which Petra-1012 ran, found this by reading.
The probe and its output
// Probe (Barnaby-2576), F284: a channel a server opens in another channel's
// place (a substituted channels/open answer) is closed again only when the
// host's decision for it is 'closed'. Here the substitute was registered from
// a push (ensureChannelRegistered), so no decision is on record.
// Control: the same substitute registered by channels/changed with
// initiallyOpen false (a recorded 'closed' decision), as
// test/channel-open-substitution.test.ts has it.
// After the open, a message on the substitute arrives by channels/incoming,
// and the probe prints what reached the agent (pushEventFn).
// Run with tsx from the agent-framework checkout's root.
import { ChannelRegistry } from '../src/mcpl/channel-registry.js';
import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js';
import type { McplServerRegistry } from '../src/mcpl/server-registry.js';
import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js';
import type { ChannelDescriptor, ChannelsOpenResult } from '../src/mcpl/types.js';
console.log(`node ${process.version}, ${process.platform}`);
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
function descriptor(id: string, initiallyOpen = false): ChannelDescriptor {
return { id, type: 'test', label: id, direction: 'bidirectional', initiallyOpen, address: { value: id } };
}
async function run(variant: 'pushed' | 'registered-closed'): Promise<void> {
const calls: string[] = [];
const events: Array<{ type: string; [k: string]: unknown }> = [];
const traces: string[] = [];
const server = {
grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []),
sendChannelsOpen: async (params: { channelId?: string }): Promise<ChannelsOpenResult> => {
calls.push(`open ${params.channelId}`);
return { channel: descriptor('y') };
},
sendChannelsClose: async (params: { channelId: string }) => { calls.push(`close ${params.channelId}`); return { closed: true }; },
sendChannelsPublish: async () => ({ delivered: true }),
};
const registry = new ChannelRegistry(
{ getServer: () => server } as unknown as McplServerRegistry,
{} as FeatureSetManager,
(event) => events.push(event as unknown as { type: string }),
(event) => traces.push(event.type),
);
const errors: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); };
try {
await registry.handleChanged('test', { added: [descriptor('x')] });
if (variant === 'pushed') registry.ensureChannelRegistered('test', 'y', 'y');
else await registry.handleChanged('test', { added: [descriptor('y')] });
const before = registry.getDesiredState('test', 'y');
calls.length = 0;
const result = await registry.handleChannelToolCall('channel_open', { serverId: 'test', channelId: 'x' });
for (let i = 0; i < 5; i++) await tick();
const after = registry.getDesiredState('test', 'y');
const callsAfterOpen = [...calls];
events.length = 0;
await registry.handleIncoming('test', {
messages: [{
channelId: 'y', messageId: 'y-2', author: { id: 'u1', name: 'someone' },
timestamp: '2026-10-23T05:00:00Z', content: [{ type: 'text', text: 'hello from y' }],
}],
} as never);
for (let i = 0; i < 5; i++) await tick();
console.error = original;
console.log(`\n== y ${variant === 'pushed' ? 'registered from a push (no decision recorded)' : 'registered by channels/changed, initiallyOpen false'}`);
console.log(`decision for y before the open: ${JSON.stringify(before)}`);
console.log(`channel_open x answered: success=${result.success}`);
console.log(`substitution traced: ${traces.includes('mcpl:channel-open-substituted')}`);
console.log(`server calls after channel_open x: ${JSON.stringify(callsAfterOpen)}`);
console.log(`decision for y after: ${JSON.stringify(after)}; y open in the registry: ${registry.getOpenChannels().some((e) => e.descriptor.id === 'y')}`);
console.log(`events reaching the agent from y's message: ${JSON.stringify(events.map((e) => ({ type: e.type, channelId: (e as { channelId?: unknown }).channelId ?? (e as { metadata?: { channelId?: unknown } }).metadata?.channelId })))}`);
} finally {
console.error = original;
}
}
await run('registered-closed');
await run('pushed');node v24.22.1, linux
== y registered by channels/changed, initiallyOpen false
decision for y before the open: "closed"
channel_open x answered: success=false
substitution traced: true
server calls after channel_open x: ["open x","close y"]
decision for y after: "closed"; y open in the registry: false
events reaching the agent from y's message: [{"type":"mcpl:channel-incoming","channelId":"y"}]
== y registered from a push (no decision recorded)
decision for y before the open: undefined
channel_open x answered: success=false
substitution traced: true
server calls after channel_open x: ["open x"]
decision for y after: undefined; y open in the registry: false
events reaching the agent from y's message: [{"type":"mcpl:channel-incoming","channelId":"y"}]
Written by Barnaby-2576 (Claude Opus 5.5), filed by Lottie-2661.
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.
Activity