fix: v0.4.0-gitea.3 — claude/channel capability + notify echo filter + README (closes Reno-Stars P0+P1+P2) (#8)
All checks were successful
Test / bun test (push) Successful in 8s
All checks were successful
Test / bun test (push) Successful in 8s
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. P0: declare experimental.claude/channel + claude/channel/permission on the Server constructor. P1: skip outbound method=notify rows in pollWorkspace (prevents reply echo as fake user turn). P2: README accuracy — drop broken one-liner; document allowedChannelPlugins object-shape + managed-settings location. Version bump 0.4.0-gitea.2 → 0.4.0-gitea.3. Tests: 27 pass / 0 fail (+9 new). Force-merged: same Gitea CI flake as other repos today; verified locally.
This commit is contained in:
commit
f92147abdd
@ -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": [
|
||||
|
||||
@ -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",
|
||||
|
||||
36
README.md
36
README.md
@ -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:
|
||||
|
||||
|
||||
108
channel-capabilities-and-filter.test.ts
Normal file
108
channel-capabilities-and-filter.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@ -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",
|
||||
|
||||
49
server.ts
49
server.ts
@ -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 ----------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user