Skip to content

A channel a server opens in another's place isn't closed again when the host has no decision recorded for it #730

Open

Description

@nissa-seru

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:

  • y registered by channels/changed, with initiallyOpen: false: the decision for y was "closed". The registry traced the substitution and sent channels/open x, then channels/close y.
  • y registered from a push: the decision for y was undefined. The registry traced the substitution and sent channels/open x only. Nothing asked the server to close y.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions