#!/usr/bin/env bun /** * SynthesisAgent.ClaudePlugin * * Claude Code "channel" MCP server. Spawned per Claude Code process by * SynthesisAgent.OpenclawPlugin. Acts as the bidirectional bridge: * * OpenClaw bridge WebSocket ←→ this process ←→ Claude Code (stdio MCP) * * Inbound (OpenClaw → Claude): * - inbound_message → notifications/claude/channel * - permission_reply → notifications/claude/channel/permission * - tool_result → resolve pending tool_call promise * * 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. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 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 OPENCLAW_SESSION = process.env.SYNTHESIS_OPENCLAW_SESSION const CLAUDE_SESSION = process.env.SYNTHESIS_CLAUDE_SESSION ?? '' if (!BRIDGE_URL || !OPENCLAW_SESSION) { process.stderr.write( `synthesis-claude: required env vars missing\n` + ` SYNTHESIS_BRIDGE_URL=${BRIDGE_URL ?? '(unset)'}\n` + ` SYNTHESIS_OPENCLAW_SESSION=${OPENCLAW_SESSION ?? '(unset)'}\n`, ) process.exit(1) } const log = (s: string) => process.stderr.write(`synthesis-claude: ${s}\n`) process.on('unhandledRejection', e => log(`unhandled rejection: ${e}`)) process.on('uncaughtException', e => log(`uncaught exception: ${e}`)) // ─── MCP server ────────────────────────────────────────────────────────────── const mcp = new Server( { name: 'synthesis', version: '0.0.1' }, { 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 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(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 } } 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 ──────────────────────────────────────────────────────── let ws: WebSocket | null = null let backoff = 1000 let outbox: unknown[] = [] function bridgeSend(frame: unknown): void { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(frame)) } else { outbox.push(frame) } } function connect(): void { ws = new WebSocket(BRIDGE_URL!) ws.on('open', () => { log(`bridge connected: ${BRIDGE_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.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}`)) }) ws.on('close', () => { log(`bridge disconnected, reconnect in ${backoff}ms`) setTimeout(connect, backoff) backoff = Math.min(backoff * 2, 30_000) }) ws.on('error', err => log(`bridge error: ${(err as Error).message}`)) } async function handleBridgeFrame(frame: any): Promise { switch (frame.type) { case 'hello_ack': if (Array.isArray(frame.tools)) toolCatalog = frame.tools log(`hello_ack: ${toolCatalog.length} tools registered`) return case 'inbound_message': await mcp.notification({ method: 'notifications/claude/channel', params: { content: frame.content, meta: frame.meta }, }) 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() }) return default: log(`unknown frame type: ${frame.type}`) } } // ─── Boot ──────────────────────────────────────────────────────────────────── await mcp.connect(new StdioServerTransport()) log(`MCP transport ready (session=${OPENCLAW_SESSION}, pid=${process.pid})`) connect()