chore: initial scaffolding for SynthesisAgent.ClaudePlugin

Stdio MCP server registered as a Claude Code --channels source. Receives
inbound events from SynthesisAgent.OpenclawPlugin and emits
notifications/claude/channel to push them into the running Claude turn loop.
Forwards tools/call requests back to OpenClaw for execution.

Capability declaration uses experimental.claude/channel — required nesting
discovered 2026-05-14 against the official Anthropic discord plugin source.
This commit is contained in:
zhi
2026-05-14 09:41:14 +00:00
commit cb94464060
7 changed files with 340 additions and 0 deletions

223
server.ts Normal file
View File

@@ -0,0 +1,223 @@
#!/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<string, (r: unknown) => 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<void> {
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()