Skip to content

channel_publish posts into a closed channel without opening it, and before a pending open is answered #729

Open

Description

@nissa-seru

channel_publish sends channels/publish once it has checked that the channel isn't inbound-only and that the grant allows publishing. Unlike plain speech, it doesn't open a closed channel first, and it doesn't wait for an open already in flight. So it posts into a channel the host holds closed, and it can post before a pending open is answered, answer success, and leave the channel unopened when that open then fails.

Plain speech goes through ChannelRegistry.publish() (src/mcpl/channel-registry.ts:5520 at main 9fdc3a65). It waits for lifecycle work already queued for the channel, opens a closed channel, and sends only into a registration confirmed open. Its comment states the rule: "it is not possible to send into a closed channel". channel_publish goes through handleToolPublish (:5796), which does none of these.

A run at main 9fdc3a65 (Linux, Node 24.22.1) used a ChannelRegistry and a server stub that records its calls. It sent the same words both ways:

  • Into a channel z registered closed:
    • channel_publish sent channels/publish z alone and answered success. z stayed decided closed and wasn't open.
    • publish() sent channels/open z, waited for its answer, then sent channels/publish z. z was open afterwards.
  • Into a channel w whose channel_open was still waiting for the server:
    • channel_publish sent channels/publish w before the open was answered and answered success. The open then failed, and w wasn't open.
    • publish() sent nothing until the open was answered, then refused: "the channel isn't confirmed open: server refused the open".

Why it matters: an agent whose channel_open is still waiting when it calls channel_publish is told the open failed and the post went out, into a channel the host never opened. And the two paths for an agent's words into a channel follow different rules about which channels they may post into.

A shape to weigh: handleToolPublish sends through publish()'s checks, waiting for queued lifecycle work, opening a closed channel as speech does, and sending only into a registration confirmed open. The open pull request #330 changes whether speech reopens a channel the agent closed. Whatever it settles for speech, the tool's path would follow too.

Cordelia-2434 found this by reading, in our internal review of #569, which changed publish()'s checks.

The probe and its output
// Probe (Barnaby-2576), F261: channel_publish's path (handleToolPublish)
// beside plain speech's (ChannelRegistry.publish), on one registry.
// A: a channel registered closed (channels/changed, initiallyOpen false).
// B: a channel whose channel_open is still waiting for the server's answer,
//    which then fails.
// For each, the probe prints the server calls in order and each path's answer.
// 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));
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

function descriptor(id: string, initiallyOpen = false): ChannelDescriptor {
  return { id, type: 'test', label: id, direction: 'bidirectional', initiallyOpen, address: { value: id } };
}

function fixture(openAnswer: (id: string) => Promise<ChannelsOpenResult>) {
  const calls: string[] = [];
  const server = {
    grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []),
    sendChannelsOpen: async (params: { channelId?: string }) => {
      calls.push(`channels/open ${params.channelId}`);
      try {
        const answer = await openAnswer(params.channelId!);
        calls.push(`channels/open ${params.channelId} answered`);
        return answer;
      } catch (err) {
        calls.push(`channels/open ${params.channelId} failed: ${(err as Error).message}`);
        throw err;
      }
    },
    sendChannelsClose: async (params: { channelId: string }) => { calls.push(`channels/close ${params.channelId}`); return { closed: true }; },
    sendChannelsPublish: async (params: { channelId: string }) => { calls.push(`channels/publish ${params.channelId}`); return { delivered: true, messageId: 'm1' }; },
  };
  const registry = new ChannelRegistry(
    { getServer: () => server } as unknown as McplServerRegistry,
    {} as FeatureSetManager, () => {}, () => {},
  );
  return { registry, calls };
}

const quiet = async <T>(work: () => Promise<T>): Promise<T> => {
  const original = console.error;
  console.error = () => {};
  try { return await work(); } finally { console.error = original; }
};

// A: a closed channel.
for (const path of ['channel_publish', 'publish()'] as const) {
  const f = fixture(async (id) => ({ channel: descriptor(id, true) }));
  await f.registry.handleChanged('test', { added: [descriptor('z')] });
  f.calls.length = 0;
  const answer = await quiet(() => path === 'channel_publish'
    ? f.registry.handleChannelToolCall('channel_publish', { channelId: 'z', content: 'hello' }).then((r) => ({ success: r.success, error: r.error }))
    : f.registry.publish('conv', 'hello', { serverId: 'test', channelId: 'z' }).then((o) => ({ status: o.status, reason: (o as { reason?: string }).reason })));
  await tick();
  console.log(`\n== A, closed channel z, via ${path}`);
  console.log(`decision for z: ${JSON.stringify(f.registry.getDesiredState('test', 'z'))}; z open after: ${f.registry.isChannelOpen({ serverId: 'test', channelId: 'z' })}`);
  console.log(`server calls: ${JSON.stringify(f.calls)}`);
  console.log(`answer: ${JSON.stringify(answer)}`);
}

// B: a channel whose open is in flight and then fails.
for (const path of ['channel_publish', 'publish()'] as const) {
  let fail!: (err: Error) => void;
  const f = fixture(() => new Promise<ChannelsOpenResult>((_resolve, reject) => { fail = reject; }));
  await f.registry.handleChanged('test', { added: [descriptor('w')] });
  f.calls.length = 0;
  const opening = quiet(() => f.registry.handleChannelToolCall('channel_open', { serverId: 'test', channelId: 'w' }));
  await sleep(10);
  const sending = quiet(() => path === 'channel_publish'
    ? f.registry.handleChannelToolCall('channel_publish', { channelId: 'w', content: 'hello' }).then((r) => ({ success: r.success, error: r.error }))
    : f.registry.publish('conv', 'hello', { serverId: 'test', channelId: 'w' }).then((o) => ({ status: o.status, reason: (o as { reason?: string }).reason })));
  await sleep(10);
  const callsBeforeAnswer = [...f.calls];
  fail(new Error('server refused the open'));
  const [opened, sent] = await Promise.all([opening, sending]);
  await tick();
  console.log(`\n== B, channel w with channel_open in flight (then failing), via ${path}`);
  console.log(`server calls before the open was answered: ${JSON.stringify(callsBeforeAnswer)}`);
  console.log(`server calls in all: ${JSON.stringify(f.calls)}`);
  console.log(`channel_open answer: success=${opened.success}`);
  console.log(`send answer: ${JSON.stringify(sent)}`);
  console.log(`w open after: ${f.registry.isChannelOpen({ serverId: 'test', channelId: 'w' })}`);
}
node v24.22.1, linux

== A, closed channel z, via channel_publish
decision for z: "closed"; z open after: false
server calls: ["channels/publish z"]
answer: {"success":true}

== A, closed channel z, via publish()
decision for z: "open"; z open after: true
server calls: ["channels/open z","channels/open z answered","channels/publish z"]
answer: {"status":"delivered"}

== B, channel w with channel_open in flight (then failing), via channel_publish
server calls before the open was answered: ["channels/open w","channels/publish w"]
server calls in all: ["channels/open w","channels/publish w","channels/open w failed: server refused the open"]
channel_open answer: success=false
send answer: {"success":true}
w open after: false

== B, channel w with channel_open in flight (then failing), via publish()
server calls before the open was answered: ["channels/open w"]
server calls in all: ["channels/open w","channels/open w failed: server refused the open"]
channel_open answer: success=false
send answer: {"status":"failed","reason":"the channel isn't confirmed open: server refused the open"}
w open after: false

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