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:
6
.claude-plugin/plugin.json
Normal file
6
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "synthesis-claude",
|
||||||
|
"description": "Claude Code channel plugin for SynthesisAgent — bridges OpenClaw events into Claude Code via notifications/claude/channel, and proxies OpenClaw tool calls back to OpenClaw.",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"keywords": ["openclaw", "channel", "mcp", "synthesis"]
|
||||||
|
}
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
bun.lock
|
||||||
|
package-lock.json
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# SynthesisAgent.ClaudePlugin
|
||||||
|
|
||||||
|
The **Claude Code side** of SynthesisAgent. A stdio MCP server that registers as a `--channels` plugin and bridges to `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.
|
||||||
|
|
||||||
|
## Required env vars
|
||||||
|
|
||||||
|
Set by OpenclawPlugin when it spawns 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` | `<uuid>` | The Claude session UUID, for traceability |
|
||||||
|
|
||||||
|
## Spawn shape (done by OpenclawPlugin)
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful run: Claude Code visibly starts a new turn in Terminal 2 reacting to "hello".
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "synthesis-claude-plugin",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"bin": "./server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun install --no-summary && bun server.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.5.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
223
server.ts
Normal file
223
server.ts
Normal 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()
|
||||||
15
skills/configure/SKILL.md
Normal file
15
skills/configure/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: synthesis-status
|
||||||
|
description: Inspect the SynthesisAgent channel — current OpenClaw session binding, bridge connection state, pending tool calls
|
||||||
|
argument-hint: ""
|
||||||
|
allowed-tools: [Bash]
|
||||||
|
---
|
||||||
|
|
||||||
|
Print SynthesisAgent ClaudePlugin status from the env + bridge:
|
||||||
|
|
||||||
|
- `SYNTHESIS_OPENCLAW_SESSION`: which OpenClaw session this Claude process serves
|
||||||
|
- `SYNTHESIS_CLAUDE_SESSION`: the resumable Claude session UUID
|
||||||
|
- `SYNTHESIS_BRIDGE_URL`: bridge endpoint
|
||||||
|
- Bridge health: last `pong` timestamp, pending tool calls
|
||||||
|
|
||||||
|
When the user asks "what session am I in?", "is the bridge connected?", or "show synthesis status", read the env vars and report.
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user