From 813f81af9a3e73ce9c5dfd3472c6b546499698e8 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 14 May 2026 13:28:14 +0000 Subject: [PATCH] feat: real channel-bridge client + reply tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WS client to OpenclawPlugin's bridge (auto-reconnect with exponential backoff) - On hello: send (openclaw_session, claude_session, pid) — no auth token - On inbound frame: emit notifications/claude/channel into MCP so Claude Code opens a new turn - Single MCP tool `reply(text, final?)` — model calls it to send a reply back via the bridge. final=false streams progress chunks - Capability declared under `experimental.claude/channel` (top-level was silently ignored — bug confirmed against Anthropic's discord plugin) - Dropped auth handshake + permission_request reverse channel per the same-machine, full-perms design call Add .mcp.json so the package is also a valid Claude Code plugin (for potential plugin: form deployment later). --- .mcp.json | 8 +++ README.md | 86 ++++++++++++----------- package.json | 3 +- server.ts | 192 +++++++++++++++++++-------------------------------- 4 files changed, 125 insertions(+), 164 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..0767c87 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "synthesis": { + "command": "bun", + "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"] + } + } +} diff --git a/README.md b/README.md index c5bfaff..da7da78 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,64 @@ # SynthesisAgent.ClaudePlugin -The **Claude Code side** of SynthesisAgent. A stdio MCP server that registers as a `--channels` plugin and bridges to `SynthesisAgent.OpenclawPlugin`. +Stdio MCP server registered as a Claude Code `--channels server:` source. Lives inside every long-lived Claude process spawned by SynthesisAgent.OpenclawPlugin. ## What it does -- Receives inbound messages from OpenClaw and emits `notifications/claude/channel` so Claude Code starts a new turn. -- Receives the OpenClaw tool catalog at `hello_ack` time and exposes them as MCP tools. -- Forwards every `tools/call` over the bridge for OpenClaw to execute. -- Forwards Claude's `permission_request` notifications to OpenClaw, and surfaces the user's reply back. +- Connects out (WebSocket) to OpenclawPlugin's bridge on startup, sends `hello` +- Listens for `inbound` frames → emits `notifications/claude/channel` so Claude opens a new turn +- Exposes a single `reply(text, final?)` MCP tool — the model calls it to send its answer back to the OpenClaw side + +## Capability declaration (important detail) + +```ts +new Server({...}, { + capabilities: { + tools: {}, + experimental: { + 'claude/channel': {}, + }, + }, +}) +``` + +Top-level placement of `'claude/channel'` is silently ignored — Claude Code logs `"server did not declare claude/channel capability"`. Verified 2026-05-14 against the official Anthropic discord plugin source. ## Required env vars -Set by OpenclawPlugin when it spawns Claude: +Set by OpenclawPlugin's process-manager when spawning claude: -| Var | Example | Meaning | -|-----|---------|---------| -| `SYNTHESIS_BRIDGE_URL` | `ws://127.0.0.1:18801/bridge` | Where to connect | -| `SYNTHESIS_BRIDGE_TOKEN` | `...` | Shared secret | -| `SYNTHESIS_OPENCLAW_SESSION` | `discord:alice` | This Claude process serves *this* session | -| `SYNTHESIS_CLAUDE_SESSION` | `` | The Claude session UUID, for traceability | +| Var | Example | +|-----|---------| +| `SYNTHESIS_WS_URL` | `ws://127.0.0.1:18901/bridge` | +| `SYNTHESIS_OPENCLAW_SESSION` | `developer::discord:channel:123` | +| `SYNTHESIS_CLAUDE_SESSION` | `` | -## Spawn shape (done by OpenclawPlugin) +## Manual registration (laptop smoke test) + +To use this MCP server with `claude --channels server:synthesis`, register it once globally: ```bash -claude \ - --channels plugin:synthesis-claude@local \ - --resume "$SYNTHESIS_CLAUDE_SESSION" \ - --allowed-tools "Read Edit Bash mcp__synthesis__*" \ - --append-system-prompt-file ./session-context.md +claude mcp add --scope user synthesis -- bun run /absolute/path/to/server.ts ``` -## TODO - -- [ ] Verify exact semantics of `--channels plugin:foo@bar` against the running Claude Code build. -- [ ] Verify `mcp.notification('notifications/claude/channel', ...)` triggers a new turn (vs being silently accepted). May need to first land on `claude/channel` capability declaration. -- [ ] Implement attachment download flow (`download_attachment` style) — currently passthrough. -- [ ] Implement `fetch_messages` to query OpenClaw history. -- [ ] Wire `bun-types` install in `package.json`. - -## Manual test (once OpenclawPlugin is wired up) +Then OpenclawPlugin can spawn: ```bash -# Terminal 1: start OpenclawPlugin bridge server -openclaw # with SynthesisAgent.OpenclawPlugin loaded - -# Terminal 2: simulate the spawn manually -SYNTHESIS_BRIDGE_URL=ws://127.0.0.1:18801/bridge \ -SYNTHESIS_BRIDGE_TOKEN=local-dev \ -SYNTHESIS_OPENCLAW_SESSION=test:1 \ -SYNTHESIS_CLAUDE_SESSION=$(uuidgen) \ -claude --channels plugin:synthesis-claude@local - -# Terminal 3: push a fake inbound message via OpenclawPlugin admin CLI -openclaw synthesis push --session test:1 --content "hello" +claude --channels server:synthesis \ + --dangerously-load-development-channels server:synthesis \ + --resume \ + --permission-mode bypassPermissions ``` -A successful run: Claude Code visibly starts a new turn in Terminal 2 reacting to "hello". +(The `--dangerously-load-development-channels` flag triggers a one-time interactive dev-mode confirmation. OpenclawPlugin's process-manager pipes "1\n" to stdin shortly after spawn to dismiss it automatically.) + +## Tool surface (v1) + +Just `reply`. The model uses Claude Code's built-in tools (Read, Edit, Bash, etc.) for any actual work; `reply` is purely the output channel. + +`reply(text, final=true)` sends a complete answer. +`reply(text, final=false)` queues a progress chunk; OpenclawPlugin buffers chunks until a final call arrives. + +## License + +Apache-2.0 (modeled after Anthropic's official `discord@claude-plugins-official` plugin). diff --git a/package.json b/package.json index 5c4527b..87a1d04 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "ws": "^8.18.0", - "zod": "^3.23.0" + "ws": "^8.18.0" }, "devDependencies": { "@types/ws": "^8.5.10" diff --git a/server.ts b/server.ts index c8f8d96..5964533 100644 --- a/server.ts +++ b/server.ts @@ -2,26 +2,21 @@ /** * SynthesisAgent.ClaudePlugin * - * Claude Code "channel" MCP server. Spawned per Claude Code process by - * SynthesisAgent.OpenclawPlugin. Acts as the bidirectional bridge: + * Spawned by SynthesisAgent.OpenclawPlugin inside every long-lived Claude + * process. Acts as a Claude Code `--channels server:` source. * - * OpenClaw bridge WebSocket ←→ this process ←→ Claude Code (stdio MCP) + * bridge WS this process Claude Code + * ────────────────────── ────────────────────────── ────────────────────── + * inbound → → → → → → notifications/claude/channel → → → new turn + * reply ← ← ← ← ← ← tools/call: reply(text) ← ← assistant turn * - * Inbound (OpenClaw → Claude): - * - inbound_message → notifications/claude/channel - * - permission_reply → notifications/claude/channel/permission - * - tool_result → resolve pending tool_call promise + * Env vars set by the spawning plugin: + * SYNTHESIS_WS_URL ws://127.0.0.1:18901/bridge + * SYNTHESIS_OPENCLAW_SESSION e.g. "developer::discord:channel:123" + * SYNTHESIS_CLAUDE_SESSION UUID, for traceability * - * Outbound (Claude → OpenClaw): - * - tools/call → bridge "tool_call" frame - * - permission_request → bridge "permission_request" frame - * - * State (process-scoped, dies with the process): - * - one WebSocket to the bridge - * - pending tool call map (call_id → resolver) - * - tool catalog (received in hello_ack) - * - * No persistent state — OpenclawPlugin owns the session DB. + * Same-machine deployment — no auth handshake, no permission_request reverse + * channel (both sides have full perms by config). */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' @@ -30,26 +25,22 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' -import { z } from 'zod' import WebSocket from 'ws' -// ─── Env ───────────────────────────────────────────────────────────────────── - -const BRIDGE_URL = process.env.SYNTHESIS_BRIDGE_URL -const BRIDGE_TOKEN = process.env.SYNTHESIS_BRIDGE_TOKEN ?? '' +const WS_URL = process.env.SYNTHESIS_WS_URL const OPENCLAW_SESSION = process.env.SYNTHESIS_OPENCLAW_SESSION const CLAUDE_SESSION = process.env.SYNTHESIS_CLAUDE_SESSION ?? '' -if (!BRIDGE_URL || !OPENCLAW_SESSION) { +if (!WS_URL || !OPENCLAW_SESSION) { process.stderr.write( `synthesis-claude: required env vars missing\n` + - ` SYNTHESIS_BRIDGE_URL=${BRIDGE_URL ?? '(unset)'}\n` + + ` SYNTHESIS_WS_URL=${WS_URL ?? '(unset)'}\n` + ` SYNTHESIS_OPENCLAW_SESSION=${OPENCLAW_SESSION ?? '(unset)'}\n`, ) process.exit(1) } -const log = (s: string) => process.stderr.write(`synthesis-claude: ${s}\n`) +const log = (s: string) => process.stderr.write(`[synthesis-claude ${new Date().toISOString()}] ${s}\n`) process.on('unhandledRejection', e => log(`unhandled rejection: ${e}`)) process.on('uncaughtException', e => log(`uncaught exception: ${e}`)) @@ -61,80 +52,52 @@ const mcp = new Server( { capabilities: { tools: {}, - // Channel capabilities MUST be nested under `experimental` — Claude Code - // gating function `poH` reads `caps.experimental['claude/channel']` and - // skips notification processing if absent. Verified 2026-05-14 by reading - // the Anthropic Discord plugin source AND watching the live debug log: - // "server X: Channel notifications skipped: server did not declare - // claude/channel capability" ← appears when these are at top level + // Channel capabilities MUST be nested under `experimental` — Claude + // Code's gating function `poH` (in pi-embedded-*.js) reads + // `caps.experimental['claude/channel']`. Top-level placement is silently + // ignored with: "server did not declare claude/channel capability". experimental: { 'claude/channel': {}, - 'claude/channel/permission': {}, }, }, }, ) -// Tool catalog is supplied by OpenclawPlugin in the hello_ack frame. -// We default to empty until then; tools/list responds immediately so Claude -// doesn't block. -let toolCatalog: Array<{ name: string; description: string; inputSchema: unknown }> = [] - -mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolCatalog })) - -// Every tool call is forwarded to OpenclawPlugin and awaits its result. -const pendingCalls = new Map void>() -let nextCallId = 0 +mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'reply', + description: + 'Send a reply back to the OpenClaw user. Call this once per inbound channel message with your final answer. ' + + 'Optional `final=false` lets you stream progress updates (each chunk is buffered server-side until a final call).', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'The reply text to send back through OpenClaw.' }, + final: { type: 'boolean', description: 'Defaults to true. Set false to stream a progress chunk.' }, + }, + required: ['text'], + }, + }, + ], +})) mcp.setRequestHandler(CallToolRequestSchema, async req => { - const callId = `tc_${++nextCallId}` - const result = await new Promise<{ ok: boolean; result?: unknown; error?: string }>(resolve => { - pendingCalls.set(callId, resolve as (r: unknown) => void) - bridgeSend({ - type: 'tool_call', - call_id: callId, - tool: req.params.name, - args: req.params.arguments ?? {}, - }) - setTimeout(() => { - if (pendingCalls.delete(callId)) { - resolve({ ok: false, error: 'bridge timeout (60s)' }) - } - }, 60_000) - }) - if (!result.ok) { - return { content: [{ type: 'text', text: `error: ${result.error ?? 'unknown'}` }], isError: true } + if (req.params.name !== 'reply') { + return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true } } - return { content: [{ type: 'text', text: JSON.stringify(result.result ?? null) }] } + const args = (req.params.arguments ?? {}) as { text?: string; final?: boolean } + const text = typeof args.text === 'string' ? args.text : '' + const final = args.final !== false + bridgeSend({ type: 'reply', content: text, final }) + return { content: [{ type: 'text', text: final ? 'reply sent' : 'reply chunk sent' }] } }) -// Claude → us → bridge: permission request from Claude -mcp.setNotificationHandler( - z.object({ - method: z.literal('notifications/claude/channel/permission_request'), - params: z.object({ - request_id: z.string(), - tool: z.string().optional(), - args: z.unknown().optional(), - rationale: z.string().optional(), - }).passthrough(), - }), - async ({ params }) => { - bridgeSend({ - type: 'permission_request', - request_id: params.request_id, - tool: params.tool ?? '', - args: params.args ?? {}, - rationale: params.rationale ?? '', - }) - }, -) - // ─── Bridge WebSocket ──────────────────────────────────────────────────────── let ws: WebSocket | null = null let backoff = 1000 -let outbox: unknown[] = [] +const outbox: unknown[] = [] function bridgeSend(frame: unknown): void { if (ws && ws.readyState === WebSocket.OPEN) { @@ -145,27 +108,28 @@ function bridgeSend(frame: unknown): void { } function connect(): void { - ws = new WebSocket(BRIDGE_URL!) + ws = new WebSocket(WS_URL!) ws.on('open', () => { - log(`bridge connected: ${BRIDGE_URL}`) + log(`bridge connected: ${WS_URL}`) backoff = 1000 - bridgeSend({ - type: 'hello', - openclaw_session: OPENCLAW_SESSION, - claude_session: CLAUDE_SESSION, - pid: process.pid, - token: BRIDGE_TOKEN, - }) - const flushing = outbox - outbox = [] - for (const f of flushing) bridgeSend(f) + ws!.send( + JSON.stringify({ + type: 'hello', + openclaw_session: OPENCLAW_SESSION, + claude_session: CLAUDE_SESSION, + pid: process.pid, + }), + ) + while (outbox.length) { + try { ws!.send(JSON.stringify(outbox.shift())) } catch { break } + } }) ws.on('message', raw => { let frame: any try { frame = JSON.parse(String(raw)) } catch { return log(`bad frame: ${raw}`) } - handleBridgeFrame(frame).catch(e => log(`handler error: ${e}`)) + handleBridge(frame).catch(e => log(`handler error: ${e}`)) }) ws.on('close', () => { @@ -177,42 +141,26 @@ function connect(): void { ws.on('error', err => log(`bridge error: ${(err as Error).message}`)) } -async function handleBridgeFrame(frame: any): Promise { +async function handleBridge(frame: any): Promise { switch (frame.type) { case 'hello_ack': - if (Array.isArray(frame.tools)) toolCatalog = frame.tools - log(`hello_ack: ${toolCatalog.length} tools registered`) + log(`hello_ack received`) return - - case 'inbound_message': + case 'inbound': await mcp.notification({ method: 'notifications/claude/channel', - params: { content: frame.content, meta: frame.meta }, + params: { + content: frame.content, + meta: frame.meta ?? {}, + }, }) + log(`inbound pushed to channel (content_len=${(frame.content ?? '').length})`) return - - case 'permission_reply': - await mcp.notification({ - method: 'notifications/claude/channel/permission', - params: { request_id: frame.request_id, behavior: frame.behavior }, - }) - return - - case 'tool_result': { - const resolver = pendingCalls.get(frame.call_id) - if (resolver) { - pendingCalls.delete(frame.call_id) - resolver({ ok: frame.ok, result: frame.result, error: frame.error }) - } - return - } - case 'ping': - bridgeSend({ type: 'pong', ts: Date.now() }) + bridgeSend({ type: 'pong' }) return - default: - log(`unknown frame type: ${frame.type}`) + log(`unknown frame type=${frame.type}`) } }