feat: real channel-bridge client + reply tool

- 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).
This commit is contained in:
zhi
2026-05-14 13:28:14 +00:00
parent cb94464060
commit 813f81af9a
4 changed files with 125 additions and 164 deletions

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"synthesis": {
"command": "bun",
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"]
}
}
}

View File

@@ -1,58 +1,64 @@
# SynthesisAgent.ClaudePlugin # 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:<name>` source. Lives inside every long-lived Claude process spawned by SynthesisAgent.OpenclawPlugin.
## What it does ## What it does
- Receives inbound messages from OpenClaw and emits `notifications/claude/channel` so Claude Code starts a new turn. - Connects out (WebSocket) to OpenclawPlugin's bridge on startup, sends `hello`
- Receives the OpenClaw tool catalog at `hello_ack` time and exposes them as MCP tools. - Listens for `inbound` frames → emits `notifications/claude/channel` so Claude opens a new turn
- Forwards every `tools/call` over the bridge for OpenClaw to execute. - Exposes a single `reply(text, final?)` MCP tool — the model calls it to send its answer back to the OpenClaw side
- Forwards Claude's `permission_request` notifications to OpenClaw, and surfaces the user's reply back.
## 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 ## Required env vars
Set by OpenclawPlugin when it spawns Claude: Set by OpenclawPlugin's process-manager when spawning claude:
| Var | Example | Meaning | | Var | Example |
|-----|---------|---------| |-----|---------|
| `SYNTHESIS_BRIDGE_URL` | `ws://127.0.0.1:18801/bridge` | Where to connect | | `SYNTHESIS_WS_URL` | `ws://127.0.0.1:18901/bridge` |
| `SYNTHESIS_BRIDGE_TOKEN` | `...` | Shared secret | | `SYNTHESIS_OPENCLAW_SESSION` | `developer::discord:channel:123` |
| `SYNTHESIS_OPENCLAW_SESSION` | `discord:alice` | This Claude process serves *this* session | | `SYNTHESIS_CLAUDE_SESSION` | `<uuid>` |
| `SYNTHESIS_CLAUDE_SESSION` | `<uuid>` | The Claude session UUID, for traceability |
## 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 ```bash
claude \ claude mcp add --scope user synthesis -- bun run /absolute/path/to/server.ts
--channels plugin:synthesis-claude@local \
--resume "$SYNTHESIS_CLAUDE_SESSION" \
--allowed-tools "Read Edit Bash mcp__synthesis__*" \
--append-system-prompt-file ./session-context.md
``` ```
## TODO Then OpenclawPlugin can spawn:
- [ ] 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)
```bash ```bash
# Terminal 1: start OpenclawPlugin bridge server claude --channels server:synthesis \
openclaw # with SynthesisAgent.OpenclawPlugin loaded --dangerously-load-development-channels server:synthesis \
--resume <uuid> \
# Terminal 2: simulate the spawn manually --permission-mode bypassPermissions
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"
``` ```
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).

View File

@@ -9,8 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"ws": "^8.18.0", "ws": "^8.18.0"
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ws": "^8.5.10" "@types/ws": "^8.5.10"

184
server.ts
View File

@@ -2,26 +2,21 @@
/** /**
* SynthesisAgent.ClaudePlugin * SynthesisAgent.ClaudePlugin
* *
* Claude Code "channel" MCP server. Spawned per Claude Code process by * Spawned by SynthesisAgent.OpenclawPlugin inside every long-lived Claude
* SynthesisAgent.OpenclawPlugin. Acts as the bidirectional bridge: * process. Acts as a Claude Code `--channels server:<channelName>` 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): * Env vars set by the spawning plugin:
* - inbound_message → notifications/claude/channel * SYNTHESIS_WS_URL ws://127.0.0.1:18901/bridge
* - permission_reply → notifications/claude/channel/permission * SYNTHESIS_OPENCLAW_SESSION e.g. "developer::discord:channel:123"
* - tool_result → resolve pending tool_call promise * SYNTHESIS_CLAUDE_SESSION UUID, for traceability
* *
* Outbound (Claude → OpenClaw): * Same-machine deployment — no auth handshake, no permission_request reverse
* - tools/call → bridge "tool_call" frame * channel (both sides have full perms by config).
* - 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.
*/ */
import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { Server } from '@modelcontextprotocol/sdk/server/index.js'
@@ -30,26 +25,22 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
CallToolRequestSchema, CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import WebSocket from 'ws' import WebSocket from 'ws'
// ─── Env ───────────────────────────────────────────────────────────────────── const WS_URL = process.env.SYNTHESIS_WS_URL
const BRIDGE_URL = process.env.SYNTHESIS_BRIDGE_URL
const BRIDGE_TOKEN = process.env.SYNTHESIS_BRIDGE_TOKEN ?? ''
const OPENCLAW_SESSION = process.env.SYNTHESIS_OPENCLAW_SESSION const OPENCLAW_SESSION = process.env.SYNTHESIS_OPENCLAW_SESSION
const CLAUDE_SESSION = process.env.SYNTHESIS_CLAUDE_SESSION ?? '' const CLAUDE_SESSION = process.env.SYNTHESIS_CLAUDE_SESSION ?? ''
if (!BRIDGE_URL || !OPENCLAW_SESSION) { if (!WS_URL || !OPENCLAW_SESSION) {
process.stderr.write( process.stderr.write(
`synthesis-claude: required env vars missing\n` + `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`, ` SYNTHESIS_OPENCLAW_SESSION=${OPENCLAW_SESSION ?? '(unset)'}\n`,
) )
process.exit(1) 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('unhandledRejection', e => log(`unhandled rejection: ${e}`))
process.on('uncaughtException', e => log(`uncaught exception: ${e}`)) process.on('uncaughtException', e => log(`uncaught exception: ${e}`))
@@ -61,80 +52,52 @@ const mcp = new Server(
{ {
capabilities: { capabilities: {
tools: {}, tools: {},
// Channel capabilities MUST be nested under `experimental` — Claude Code // Channel capabilities MUST be nested under `experimental` — Claude
// gating function `poH` reads `caps.experimental['claude/channel']` and // Code's gating function `poH` (in pi-embedded-*.js) reads
// skips notification processing if absent. Verified 2026-05-14 by reading // `caps.experimental['claude/channel']`. Top-level placement is silently
// the Anthropic Discord plugin source AND watching the live debug log: // ignored with: "server did not declare claude/channel capability".
// "server X: Channel notifications skipped: server did not declare
// claude/channel capability" ← appears when these are at top level
experimental: { experimental: {
'claude/channel': {}, 'claude/channel': {},
'claude/channel/permission': {},
}, },
}, },
}, },
) )
// Tool catalog is supplied by OpenclawPlugin in the hello_ack frame. mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
// We default to empty until then; tools/list responds immediately so Claude tools: [
// doesn't block. {
let toolCatalog: Array<{ name: string; description: string; inputSchema: unknown }> = [] name: 'reply',
description:
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolCatalog })) '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).',
// Every tool call is forwarded to OpenclawPlugin and awaits its result. inputSchema: {
const pendingCalls = new Map<string, (r: unknown) => void>() type: 'object',
let nextCallId = 0 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 => { mcp.setRequestHandler(CallToolRequestSchema, async req => {
const callId = `tc_${++nextCallId}` if (req.params.name !== 'reply') {
const result = await new Promise<{ ok: boolean; result?: unknown; error?: string }>(resolve => { return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }
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) const args = (req.params.arguments ?? {}) as { text?: string; final?: boolean }
}) const text = typeof args.text === 'string' ? args.text : ''
if (!result.ok) { const final = args.final !== false
return { content: [{ type: 'text', text: `error: ${result.error ?? 'unknown'}` }], isError: true } bridgeSend({ type: 'reply', content: text, final })
} return { content: [{ type: 'text', text: final ? 'reply sent' : 'reply chunk sent' }] }
return { content: [{ type: 'text', text: JSON.stringify(result.result ?? null) }] }
}) })
// 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 ──────────────────────────────────────────────────────── // ─── Bridge WebSocket ────────────────────────────────────────────────────────
let ws: WebSocket | null = null let ws: WebSocket | null = null
let backoff = 1000 let backoff = 1000
let outbox: unknown[] = [] const outbox: unknown[] = []
function bridgeSend(frame: unknown): void { function bridgeSend(frame: unknown): void {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
@@ -145,27 +108,28 @@ function bridgeSend(frame: unknown): void {
} }
function connect(): void { function connect(): void {
ws = new WebSocket(BRIDGE_URL!) ws = new WebSocket(WS_URL!)
ws.on('open', () => { ws.on('open', () => {
log(`bridge connected: ${BRIDGE_URL}`) log(`bridge connected: ${WS_URL}`)
backoff = 1000 backoff = 1000
bridgeSend({ ws!.send(
JSON.stringify({
type: 'hello', type: 'hello',
openclaw_session: OPENCLAW_SESSION, openclaw_session: OPENCLAW_SESSION,
claude_session: CLAUDE_SESSION, claude_session: CLAUDE_SESSION,
pid: process.pid, pid: process.pid,
token: BRIDGE_TOKEN, }),
}) )
const flushing = outbox while (outbox.length) {
outbox = [] try { ws!.send(JSON.stringify(outbox.shift())) } catch { break }
for (const f of flushing) bridgeSend(f) }
}) })
ws.on('message', raw => { ws.on('message', raw => {
let frame: any let frame: any
try { frame = JSON.parse(String(raw)) } catch { return log(`bad frame: ${raw}`) } 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', () => { ws.on('close', () => {
@@ -177,42 +141,26 @@ function connect(): void {
ws.on('error', err => log(`bridge error: ${(err as Error).message}`)) ws.on('error', err => log(`bridge error: ${(err as Error).message}`))
} }
async function handleBridgeFrame(frame: any): Promise<void> { async function handleBridge(frame: any): Promise<void> {
switch (frame.type) { switch (frame.type) {
case 'hello_ack': case 'hello_ack':
if (Array.isArray(frame.tools)) toolCatalog = frame.tools log(`hello_ack received`)
log(`hello_ack: ${toolCatalog.length} tools registered`)
return return
case 'inbound':
case 'inbound_message':
await mcp.notification({ await mcp.notification({
method: 'notifications/claude/channel', 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 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': case 'ping':
bridgeSend({ type: 'pong', ts: Date.now() }) bridgeSend({ type: 'pong' })
return return
default: default:
log(`unknown frame type: ${frame.type}`) log(`unknown frame type=${frame.type}`)
} }
} }