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:
192
server.ts
192
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:<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):
|
||||
* - 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<string, (r: unknown) => 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<void> {
|
||||
async function handleBridge(frame: any): Promise<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user