diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d5b77c9..4aa1b91 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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": [ diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 895170a..aee9160 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/README.md b/README.md index 6e8b231..5fe98fc 100644 --- a/README.md +++ b/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 `#` 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 `#` 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": "", "marketplace": "" }`. 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: diff --git a/channel-capabilities-and-filter.test.ts b/channel-capabilities-and-filter.test.ts new file mode 100644 index 0000000..8683460 --- /dev/null +++ b/channel-capabilities-and-filter.test.ts @@ -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. 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 => ({ 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> = [ + 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']) + }) +}) diff --git a/package.json b/package.json index d26c59c..d1156a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.ts b/server.ts index 2b35359..e479db8 100644 --- a/server.ts +++ b/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): 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 { 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 { // 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 ----------------------------------------------