fix: v0.4.0-gitea.3 — claude/channel capability + notify echo filter + README accuracy
All checks were successful
Test / bun test (pull_request) Successful in 23s

Reno-Stars (airenostars) verified install end-to-end against v0.4.0-gitea.2
ONLY after applying a local patch. This ships the same patch upstream so
fresh self-hosters get a working install out-of-the-box, no per-host
workaround required.

P0 (CRITICAL) — declare experimental.claude/channel capability
The Server constructor previously declared `{ capabilities: { tools: {} } }`.
Without `experimental['claude/channel']` (and the companion
`claude/channel/permission` flag), Claude Code's MCP host treats the
server as tool-only and silently drops every `notifications/claude/channel`
event we emit. Symptom: poll loop runs cleanly, cursor advances, stderr
says "delivered", message never reaches the conversation. Mirrors the
shape used by the official telegram channel plugin's MCP server.

P1 — filter outbound `method=notify` rows in pollWorkspace
The activity feed under `?type=a2a_receive` ALSO returns the agent's own
outbound /notify calls (recorded with method='notify' and source_id=null
on the same workspace_id). emitNotification would classify them as
canvas_user inbound and the reply would echo back as a fake user turn one
poll later — the model would see its own answer as a new user prompt and
try to "respond" to it. Filtered at the per-row layer via a new pure
helper `shouldEmitActivity` so the cursor still advances past the
skipped rows.

P2 — README accuracy
- Drop the `claude --channels plugin:…` one-liner instruction (silently
  no-ops on Claude Code 2.1.129; only the marketplace flow works).
- Document `allowedChannelPlugins` schema: it's an array of OBJECTS
  `{ plugin, marketplace }`, not strings — the host's Zod validator
  silently ignores string entries, which is the most common cause of
  "plugin installed but no notifications" reports.
- Document `allowedChannelPlugins` LOCATION: only takes effect from the
  managed-settings file (/Library/Application Support/ClaudeCode/
  managed-settings.json on macOS, /etc/claude-code/managed-settings.json
  on Linux), NOT from `~/.claude/settings.json`. Most self-hosters try
  user settings first.

Tests
Added channel-capabilities-and-filter.test.ts (9 cases) that pin both
regressions via two small exported surfaces (`SERVER_CAPABILITIES`,
`shouldEmitActivity`). Verified the new tests fail when each fix is
reverted: removing the experimental block makes 2 tests fail; removing
the notify-method filter makes 2 tests fail. 27 pass / 0 fail (was 18).

Version bump (all four manifests + the Server() literal):
0.4.0-gitea.2 → 0.4.0-gitea.3.

Closes Reno-Stars feedback P0+P1+P2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent 73367764f9
commit 00799b45c6
6 changed files with 193 additions and 6 deletions

View File

@ -13,7 +13,7 @@
"url": "https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git"
},
"description": "Bridges Molecule A2A traffic into a Claude Code session via MCP. Subscribe to one or more Molecule workspaces; A2A messages from peers surface as conversation turns; replies route back through Molecule's A2A endpoints.",
"version": "0.4.0-gitea.2",
"version": "0.4.0-gitea.3",
"homepage": "https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel",
"license": "Apache-2.0",
"keywords": [

View File

@ -1,7 +1,7 @@
{
"name": "molecule",
"description": "Molecule AI channel for Claude Code — bridges Molecule A2A traffic into a Claude Code session via MCP. Subscribe to one or more Molecule workspaces; A2A messages from peers surface as conversation turns; replies route back through Molecule's A2A endpoints.",
"version": "0.4.0-gitea.2",
"version": "0.4.0-gitea.3",
"keywords": [
"molecule",
"molecule-ai",

View File

@ -28,9 +28,43 @@ claude plugin install molecule@molecule-channel
`molecule` is the plugin name (from `.claude-plugin/plugin.json`); `molecule-channel` is the marketplace name (from `.claude-plugin/marketplace.json`). Both live in the same repo — installing the marketplace makes the plugin available; installing the plugin enables it for your sessions.
To pin a specific version, append `#<tag>` to the marketplace URL — for example `…/molecule-mcp-claude-channel.git#v0.4.0-gitea.1`. Without a ref, you track `main`.
To pin a specific version, append `#<tag>` to the marketplace URL — for example `…/molecule-mcp-claude-channel.git#v0.4.0-gitea.3`. Without a ref, you track `main`.
> **Note for users coming from the GitHub install path**: the GitHub `Molecule-AI` org was suspended on 2026-05-06 and is permanently gone. The earlier `claude --channels plugin:molecule@Molecule-AI/...` invocation no longer resolves. The new path (above) is the canonical replacement; behavior is unchanged.
>
> **Don't use the `claude --channels plugin:…` one-liner.** It silently no-ops on Claude Code 2.1.129 (and likely 2.1.x in general). The marketplace flow above is the only path that actually registers the plugin. If a previous setup guide pointed you at `claude --channels plugin:molecule@…`, ignore it.
### Allowing the channel via `allowedChannelPlugins`
The Claude Code host gates channel-plugin notifications behind an explicit allow-list. The plugin won't deliver `notifications/claude/channel` events to your session unless this list contains an entry that matches.
**Schema.** `allowedChannelPlugins` is an array of **objects**, not strings. The shape is `{ "plugin": "<plugin-name>", "marketplace": "<marketplace-name>" }`. The host's Zod validator silently ignores entries that aren't objects in this shape — so a bare-string entry like `"molecule"` or `"molecule@molecule-channel"` will load without error and contribute nothing to the allow-list. The symptom: poll loop runs cleanly, cursor advances, stderr says "delivered", and the message never reaches the conversation.
For this plugin, the entry is:
```json
{ "plugin": "molecule", "marketplace": "molecule-channel" }
```
**Location.** `allowedChannelPlugins` only takes effect from the **managed-settings** file:
- macOS: `/Library/Application Support/ClaudeCode/managed-settings.json`
- Linux: `/etc/claude-code/managed-settings.json`
- Windows: `C:\ProgramData\ClaudeCode\managed-settings.json`
Putting it in your user-level `~/.claude/settings.json` (or `~/.claude/settings.local.json`) does **not** work — the host reads the field only from the managed location. Most self-hosters try the user-level file first; this is the single most common reason a freshly-installed channel plugin appears to do nothing. The managed-settings file may need `sudo` to edit on macOS/Linux.
A minimal working `managed-settings.json`:
```json
{
"allowedChannelPlugins": [
{ "plugin": "molecule", "marketplace": "molecule-channel" }
]
}
```
After editing, restart Claude Code (or `/reload-plugins`) for the host to re-read the file.
On first launch the plugin creates `~/.claude/channels/molecule/` and exits with a config-missing error pointing at `.env`. Fill it in:

View File

@ -0,0 +1,108 @@
// channel-capabilities-and-filter.test.ts — pins the two regressions Reno-Stars
// caught in their local-patched verify of v0.4.0-gitea.2:
//
// P0. Server constructor must declare `experimental.claude/channel` and
// `experimental.claude/channel/permission` capabilities. Without
// these, the Claude Code MCP host treats the server as tool-only and
// silently drops every `notifications/claude/channel` event we emit
// — poll advances, cursor moves, stderr says "delivered", message
// never reaches the user.
//
// P1. pollWorkspace must skip outbound `method=notify` rows. The
// activity feed returns the agent's own /notify calls alongside
// inbound A2A; emitNotification classifies them as canvas_user
// (source_id=null) and the reply echoes back as a fake user turn
// one poll later.
//
// Both regressions are silent — green tests + green CI today, broken
// behavior in production. Pin the shape so a future refactor that drops
// either fix surfaces here.
//
// Imports from ./server.ts are safe because tests/setup.ts (preloaded
// via bunfig.toml) sets the three required env vars before any test
// file is imported.
import { describe, expect, test } from 'bun:test'
import {
SERVER_CAPABILITIES,
shouldEmitActivity,
} from './server.ts'
import type { ActivityEntry } from './extract-text.ts'
describe('SERVER_CAPABILITIES — P0 channel-capability declaration', () => {
test('declares experimental.claude/channel', () => {
expect(SERVER_CAPABILITIES).toBeDefined()
expect(SERVER_CAPABILITIES.experimental).toBeDefined()
// The presence of the key is what the host checks. Empty object is
// intentional — the channel capability has no negotiable sub-fields
// today; it's a marker for "this server emits notifications/claude/channel".
expect(SERVER_CAPABILITIES.experimental['claude/channel']).toBeDefined()
expect(typeof SERVER_CAPABILITIES.experimental['claude/channel']).toBe('object')
})
test('declares experimental.claude/channel/permission', () => {
// Companion flag the host gates channel-write permission prompts on.
// Required pair — telegram-channel reference declares both.
expect(SERVER_CAPABILITIES.experimental['claude/channel/permission']).toBeDefined()
expect(typeof SERVER_CAPABILITIES.experimental['claude/channel/permission']).toBe('object')
})
test('still declares tools (regression: don\'t lose the tools surface)', () => {
// The pre-fix capability object was `{ tools: {} }`; this test pins
// that adding the experimental block didn't accidentally drop tools,
// which would break reply_to_workspace / list_peers / delegate_task.
expect(SERVER_CAPABILITIES.tools).toBeDefined()
})
})
describe('shouldEmitActivity — P1 outbound /notify echo filter', () => {
// Construct just enough of an ActivityEntry to satisfy the helper's
// Pick<ActivityEntry, 'method'>. The helper is intentionally narrow —
// it only reads .method — so the test doesn't need to mock the rest.
const make = (method: string | null): Pick<ActivityEntry, 'method'> => ({ method })
test('skips method="notify" rows (the agent\'s own outbound echoes)', () => {
expect(shouldEmitActivity(make('notify'))).toBe(false)
})
test('emits method="message/send" rows (inbound peer A2A)', () => {
// The dominant inbound shape: peers POST /workspaces/:id/a2a with
// a JSON-RPC message/send envelope; the platform records that as
// method="message/send" on the destination workspace.
expect(shouldEmitActivity(make('message/send'))).toBe(true)
})
test('emits method="user_message" rows (canvas-user inbound)', () => {
// Canvas chat panel sends method="user_message" — these surface
// as canvas_user kind to Claude.
expect(shouldEmitActivity(make('user_message'))).toBe(true)
})
test('emits null-method rows (inbound, method missing on platform side)', () => {
// Defensive: platform older than #2354 may have null method on some
// rows; deliver them rather than silently dropping. canvas_user
// classification will fall back to "no peer_id" → treat as canvas-user.
expect(shouldEmitActivity(make(null))).toBe(true)
})
test('emits any non-"notify" method even unrecognised ones', () => {
// Forward-compat: a future platform version could add a new method
// string. Default-allow + explicit-deny on "notify" is the safer
// policy than default-deny + explicit-allow on a known list.
expect(shouldEmitActivity(make('something/new'))).toBe(true)
})
test('integration: emitting twice in a batch where one is notify yields one emission', () => {
// Models the real pollWorkspace loop shape: filter pass count must
// equal "non-notify rows", regardless of order.
const batch: Array<Pick<ActivityEntry, 'method'>> = [
make('notify'), // own echo — drop
make('message/send'), // peer A2A — emit
make('notify'), // another own echo — drop
make('user_message'), // canvas user — emit
]
const emitted = batch.filter(shouldEmitActivity)
expect(emitted).toHaveLength(2)
expect(emitted.map(a => a.method)).toEqual(['message/send', 'user_message'])
})
})

View File

@ -1,6 +1,6 @@
{
"name": "molecule-mcp-claude-channel",
"version": "0.4.0-gitea.2",
"version": "0.4.0-gitea.3",
"description": "Molecule AI channel for Claude Code — bridges A2A traffic into a Claude Code session via MCP",
"license": "Apache-2.0",
"type": "module",

View File

@ -244,6 +244,29 @@ function saveCursors(): void {
}
}
// Per-row inbound filter for the activity feed. The `?type=a2a_receive`
// query already restricts the kind, but the platform STILL returns the
// agent's own outbound /notify rows in that view — they're recorded as
// a2a_receive on the SAME workspace_id with method='notify' and a null
// source_id. emitNotification would then classify them as `canvas_user`
// inbound (because peer_id is empty), and every reply this plugin sent
// would echo back as a fake user turn one poll later — the model would
// see its own answer as a new user prompt and try to "respond" to it,
// burning tokens and confusing the conversation.
//
// Filter on the row level so the cursor still advances past these rows
// (the caller already advances cursor to activities[last].id regardless
// of skip/emit, so a long run of notify-only rows can't stall the cursor).
//
// Reno-Stars caught this as the v0.4.0-gitea.2 → .3 P1 fix. Exported so
// a regression test can pin the contract without standing up a fake
// activity-feed HTTP fixture just to assert one boolean.
export function shouldEmitActivity(act: Pick<ActivityEntry, 'method'>): boolean {
// Outbound /notify calls (this agent's own replies) — silently drop.
if (act.method === 'notify') return false
return true
}
async function pollWorkspace(workspaceId: string, mcp: Server): Promise<void> {
const token = TOKEN_BY_WORKSPACE.get(workspaceId)!
const url = new URL(`${PLATFORM_URL}/workspaces/${workspaceId}/activity`)
@ -328,6 +351,7 @@ async function pollWorkspace(workspaceId: string, mcp: Server): Promise<void> {
// notification delivery is best-effort anyway.
if (activities.length === 0) return
for (const act of activities) {
if (!shouldEmitActivity(act)) continue
emitNotification(mcp, workspaceId, act)
}
const newest = activities[activities.length - 1].id
@ -488,9 +512,30 @@ function emitNotification(mcp: Server, workspaceId: string, act: ActivityEntry):
// ─── MCP server ─────────────────────────────────────────────────────────
// Capabilities: declaring `experimental['claude/channel']` is what makes the
// Claude Code MCP host actually deliver our `notifications/claude/channel`
// events into the conversation. Without it the host treats this server as
// tool-only and silently drops every channel notification — the poll
// advances, the cursor moves, stderr says "delivered", and yet no message
// reaches the user. The companion `claude/channel/permission` flag opts the
// server into the permission-prompt path the host gates channel writes on.
//
// Reno-Stars caught this as the v0.4.0-gitea.2 → .3 P0 fix; mirrors the
// shape used by the official telegram channel plugin's MCP server.
//
// Exported so a regression test can pin the shape without spinning up a
// real Server / stdio transport.
export const SERVER_CAPABILITIES = {
tools: {},
experimental: {
'claude/channel': {},
'claude/channel/permission': {},
},
} as const
const mcp = new Server(
{ name: 'molecule', version: '0.3.0' },
{ capabilities: { tools: {} } },
{ name: 'molecule', version: '0.4.0-gitea.3' },
{ capabilities: SERVER_CAPABILITIES },
)
// Tool: reply_to_workspace ----------------------------------------------