@particle-academy/agent-integrations 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/chunk-OIX2ANFS.js +386 -0
- package/dist/chunk-OIX2ANFS.js.map +1 -0
- package/dist/relay-server/index.d.cts +134 -0
- package/dist/relay-server/index.d.ts +134 -0
- package/dist/relay-server-cli.cjs +483 -0
- package/dist/relay-server-cli.cjs.map +1 -0
- package/dist/relay-server-cli.js +98 -0
- package/dist/relay-server-cli.js.map +1 -0
- package/dist/relay-server.cjs +389 -0
- package/dist/relay-server.cjs.map +1 -0
- package/dist/relay-server.js +3 -0
- package/dist/relay-server.js.map +1 -0
- package/docs/agent-hookable-demos.md +402 -0
- package/docs/relay-server.md +126 -0
- package/package.json +10 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/relay-server/core.ts","../src/relay-server/node.ts"],"names":["randomBytes","next","createHash","timingSafeEqual","URL","ok"],"mappings":"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","file":"relay-server.cjs","sourcesContent":["import { createHash, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n/**\n * RelayBroker — pure logic for the SSE+POST tunnel described in\n * docs/relay-protocol.md, hostable in any Node-compatible runtime\n * (Node, Bun, Deno-with-Node-compat, Cloudflare Workers via the Web\n * standards subset). No HTTP framework opinions; this class just\n * stores sessions, validates tokens, enqueues frames, and produces\n * SSE event payloads ready to flush.\n *\n * const broker = new RelayBroker();\n * const reg = broker.register(\"session-id\", \"token\"); // ok / error\n * broker.inbox(\"session-id\", \"token\", '{\"jsonrpc\":\"2.0\",…}'); // enqueue inbound\n * const sub = broker.subscribe(\"session-id\", \"token\", \"inbound\");\n * for await (const payload of sub.frames()) yield encodeSse(payload);\n *\n * Storage is an in-memory Map by default — fine for a single relay\n * process. To run multiple instances behind a load balancer, swap\n * `MemoryStore` for a Redis-backed equivalent (same Store interface).\n */\n\nexport type Direction = \"inbound\" | \"outbound\";\n\nexport type Session = {\n id: string;\n /** SHA-256 hex of the original token. Compared with timing-safe equals. */\n tokenHash: string;\n /** Last touched (ms since epoch). Used for TTL cleanup. */\n lastSeen: number;\n};\n\nexport type Subscriber = {\n id: string;\n direction: Direction;\n queue: string[];\n resolveNext: ((frame: string | null) => void) | null;\n};\n\nexport type RelayBrokerOptions = {\n /** Sessions auto-expire after this many ms of inactivity. Default 4h. */\n ttlMs?: number;\n /** Cleanup tick interval in ms. Default 60 000. */\n reapIntervalMs?: number;\n /** Bring-your-own storage layer (redis, etc.). Defaults to in-memory. */\n store?: Store;\n};\n\nexport interface Store {\n putSession(s: Session): void;\n getSession(id: string): Session | undefined;\n deleteSession(id: string): void;\n /** Used by the reap tick — return ids whose lastSeen < cutoff. */\n expiredSessionIds(cutoff: number): string[];\n}\n\nclass MemoryStore implements Store {\n private sessions = new Map<string, Session>();\n putSession(s: Session) { this.sessions.set(s.id, s); }\n getSession(id: string) { return this.sessions.get(id); }\n deleteSession(id: string) { this.sessions.delete(id); }\n expiredSessionIds(cutoff: number) {\n const out: string[] = [];\n for (const [id, s] of this.sessions) if (s.lastSeen < cutoff) out.push(id);\n return out;\n }\n}\n\nconst SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,64}$/;\n\nexport class RelayBroker {\n private readonly ttlMs: number;\n private readonly store: Store;\n /** Per-session, per-direction subscriber list. */\n private subs: Map<string, Map<string, Map<string, Subscriber>>> = new Map();\n private reaper?: ReturnType<typeof setInterval>;\n\n constructor(opts: RelayBrokerOptions = {}) {\n this.ttlMs = opts.ttlMs ?? 4 * 60 * 60 * 1000; // 4h\n this.store = opts.store ?? new MemoryStore();\n const tick = opts.reapIntervalMs ?? 60_000;\n if (tick > 0) {\n this.reaper = setInterval(() => this.reap(), tick);\n // Don't keep the process alive just for the reaper.\n if (typeof (this.reaper as { unref?: () => void }).unref === \"function\") {\n (this.reaper as { unref: () => void }).unref();\n }\n }\n }\n\n dispose() {\n if (this.reaper) clearInterval(this.reaper);\n this.subs.clear();\n }\n\n /** Register a session id + token. Idempotent — same id+token re-registers,\n * different token fails. */\n register(id: string, token: string): { ok: true } | { ok: false; reason: string } {\n if (!SESSION_ID_PATTERN.test(id)) return { ok: false, reason: \"invalid_session_id\" };\n if (typeof token !== \"string\" || token.length < 16 || token.length > 128) {\n return { ok: false, reason: \"invalid_token\" };\n }\n const existing = this.store.getSession(id);\n const hash = sha256Hex(token);\n if (existing) {\n if (!timingSafeEqualHex(existing.tokenHash, hash)) return { ok: false, reason: \"session_taken\" };\n existing.lastSeen = Date.now();\n this.store.putSession(existing);\n return { ok: true };\n }\n this.store.putSession({ id, tokenHash: hash, lastSeen: Date.now() });\n return { ok: true };\n }\n\n unregister(id: string, token: string): boolean {\n if (!this.validate(id, token)) return false;\n this.store.deleteSession(id);\n this.subs.delete(id);\n return true;\n }\n\n /** Validate an authenticated touch and slide the TTL forward. */\n validate(id: string, token: string): boolean {\n if (!id || !token) return false;\n const s = this.store.getSession(id);\n if (!s) return false;\n if (!timingSafeEqualHex(s.tokenHash, sha256Hex(token))) return false;\n s.lastSeen = Date.now();\n this.store.putSession(s);\n return true;\n }\n\n /** Push a frame onto the inbound queue (external agent → browser). */\n inbox(id: string, token: string, payload: string): boolean {\n if (!this.validate(id, token)) return false;\n if (!this.isFrame(payload)) return false;\n this.fanOut(id, \"inbound\", payload);\n return true;\n }\n\n /** Push a frame onto the outbound queue (browser server → external agents). */\n outbox(id: string, token: string, payload: string): boolean {\n if (!this.validate(id, token)) return false;\n if (!this.isFrame(payload)) return false;\n this.fanOut(id, \"outbound\", payload);\n return true;\n }\n\n /**\n * Subscribe to a session's queue for one direction. Returns an iterable\n * the caller (an HTTP handler) pumps as SSE.\n */\n subscribe(id: string, token: string, direction: Direction): SubscribeResult {\n if (!this.validate(id, token)) return { ok: false, reason: \"invalid_token\" };\n const subscriberId = randomBytes(8).toString(\"hex\");\n const subscriber: Subscriber = { id: subscriberId, direction, queue: [], resolveNext: null };\n this.getDirSubs(id, direction).set(subscriberId, subscriber);\n\n // Notify the inbound side (= browser) that an outbound subscriber\n // (= external agent) just connected.\n if (direction === \"outbound\") {\n this.fanOut(\n id,\n \"inbound\",\n JSON.stringify({\n jsonrpc: \"2.0\",\n method: \"notifications/peer_joined\",\n params: { subscriberId, ts: Date.now() },\n }),\n );\n }\n\n const unsubscribe = () => {\n this.getDirSubs(id, direction).delete(subscriberId);\n if (direction === \"outbound\") {\n this.fanOut(\n id,\n \"inbound\",\n JSON.stringify({\n jsonrpc: \"2.0\",\n method: \"notifications/peer_left\",\n params: { subscriberId, ts: Date.now() },\n }),\n );\n }\n };\n\n /** Async generator the HTTP handler drains. Yields raw frame payloads;\n * the handler is responsible for SSE framing (`event: mcp\\ndata: …`). */\n const frames = async function* (this: Subscriber): AsyncGenerator<string, void, void> {\n while (true) {\n if (this.queue.length > 0) {\n const next = this.queue.shift();\n if (next !== undefined) yield next;\n continue;\n }\n const next = await new Promise<string | null>((resolve) => {\n this.resolveNext = resolve;\n });\n this.resolveNext = null;\n if (next === null) return;\n yield next;\n }\n }.bind(subscriber);\n\n return {\n ok: true,\n subscriberId,\n frames: frames(),\n unsubscribe: () => {\n // Wake the generator and let it return cleanly.\n subscriber.resolveNext?.(null);\n unsubscribe();\n },\n };\n }\n\n // ────────────────────────────────────────────────────────────── internals\n\n private getDirSubs(sessionId: string, direction: Direction): Map<string, Subscriber> {\n let bySession = this.subs.get(sessionId);\n if (!bySession) {\n bySession = new Map();\n this.subs.set(sessionId, bySession);\n }\n let byDir = bySession.get(direction);\n if (!byDir) {\n byDir = new Map();\n bySession.set(direction, byDir);\n }\n return byDir;\n }\n\n private fanOut(sessionId: string, direction: Direction, payload: string) {\n const dir = this.subs.get(sessionId)?.get(direction);\n if (!dir) return;\n for (const sub of dir.values()) {\n sub.queue.push(payload);\n sub.resolveNext?.(sub.queue.shift() ?? null);\n }\n }\n\n private isFrame(payload: string): boolean {\n return payload.length > 0 && payload.includes('\"jsonrpc\"');\n }\n\n private reap() {\n const cutoff = Date.now() - this.ttlMs;\n for (const id of this.store.expiredSessionIds(cutoff)) {\n const dirs = this.subs.get(id);\n if (dirs) {\n for (const dir of dirs.values()) {\n for (const sub of dir.values()) sub.resolveNext?.(null);\n }\n }\n this.subs.delete(id);\n this.store.deleteSession(id);\n }\n }\n}\n\nexport type SubscribeResult =\n | { ok: false; reason: string }\n | {\n ok: true;\n subscriberId: string;\n frames: AsyncGenerator<string, void, void>;\n unsubscribe: () => void;\n };\n\nfunction sha256Hex(input: string): string {\n return createHash(\"sha256\").update(input).digest(\"hex\");\n}\n\nfunction timingSafeEqualHex(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n return timingSafeEqual(Buffer.from(a, \"hex\"), Buffer.from(b, \"hex\"));\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { URL } from \"node:url\";\nimport { RelayBroker, type RelayBrokerOptions } from \"./core\";\n\n/**\n * Node HTTP adapter for {@link RelayBroker}. Returns a single request\n * handler plus per-route handlers, so you can either drop it into\n * `http.createServer(...)` directly or mount the individual handlers\n * onto your existing Node HTTP framework (Express, Hono w/ node-adapter,\n * native http).\n *\n * const relay = createNodeRelay({ pathPrefix: \"/mcp-relay\" });\n * http.createServer(relay.handler).listen(8787);\n *\n * // Or piecemeal:\n * app.post(\"/mcp-relay/register\", relay.register);\n * app.post(\"/mcp-relay/:s/inbox\", relay.inbox);\n * app.post(\"/mcp-relay/:s/outbox\", relay.outbox);\n * app.get (\"/mcp-relay/:s/events\", relay.events);\n * app.post(\"/mcp-relay/:s/unregister\", relay.unregister);\n */\n\nexport type NodeRelayOptions = RelayBrokerOptions & {\n /** URL path prefix (without trailing slash). Default `\"\"` — handlers\n * expect paths like `/register`, `/{id}/inbox`, etc. directly. */\n pathPrefix?: string;\n /** Comma-separated origins (or `*`) for CORS. Default `*` — relays\n * are typically called cross-origin from the demo host. */\n corsAllowOrigin?: string;\n};\n\nexport type NodeHandler = (req: IncomingMessage, res: ServerResponse) => unknown | Promise<unknown>;\n\nexport type NodeRelay = {\n broker: RelayBroker;\n /** Single-handler shape — routes internally based on method + URL. */\n handler: NodeHandler;\n /** Per-route handlers. Each handler ignores the URL prefix and\n * acts on the path remainder, so you can mount them under any\n * prefix in your existing app. */\n register: NodeHandler;\n inbox: NodeHandler;\n outbox: NodeHandler;\n events: NodeHandler;\n unregister: NodeHandler;\n};\n\nexport function createNodeRelay(opts: NodeRelayOptions = {}): NodeRelay {\n const broker = new RelayBroker(opts);\n const prefix = (opts.pathPrefix ?? \"\").replace(/\\/$/, \"\");\n const cors = opts.corsAllowOrigin ?? \"*\";\n\n function setCorsHeaders(res: ServerResponse) {\n res.setHeader(\"access-control-allow-origin\", cors);\n res.setHeader(\"access-control-allow-methods\", \"GET, POST, OPTIONS\");\n res.setHeader(\"access-control-allow-headers\", \"content-type, x-csrf-token, accept\");\n res.setHeader(\"access-control-max-age\", \"86400\");\n }\n\n function readBody(req: IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let bytes = 0;\n req.on(\"data\", (chunk: Buffer) => {\n bytes += chunk.length;\n // Cap individual frames at 256 KB — protect against runaway payloads.\n if (bytes > 256 * 1024) {\n reject(new Error(\"payload_too_large\"));\n req.destroy();\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve(Buffer.concat(chunks).toString(\"utf8\")));\n req.on(\"error\", reject);\n });\n }\n\n function json(res: ServerResponse, status: number, body: unknown): void {\n res.statusCode = status;\n res.setHeader(\"content-type\", \"application/json\");\n res.end(JSON.stringify(body));\n }\n\n function getQuery(req: IncomingMessage): URLSearchParams {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n return u.searchParams;\n }\n\n function getPathname(req: IncomingMessage): string {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n return u.pathname;\n }\n\n const register: NodeHandler = async (req, res) => {\n setCorsHeaders(res);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"POST\") return json(res, 405, { error: \"method_not_allowed\" });\n let body: string;\n try { body = await readBody(req); } catch (e) {\n return json(res, 413, { error: e instanceof Error ? e.message : \"payload_error\" });\n }\n let parsed: unknown;\n try { parsed = JSON.parse(body); } catch { return json(res, 400, { error: \"invalid_json\" }); }\n const { session, token } = (parsed ?? {}) as { session?: string; token?: string };\n if (typeof session !== \"string\" || typeof token !== \"string\") {\n return json(res, 400, { error: \"missing_fields\" });\n }\n const result = broker.register(session, token);\n if (!result.ok) return json(res, 401, { error: result.reason });\n return json(res, 200, { ok: true });\n };\n\n /** Handler for endpoints with a `{session}` segment. The path matcher\n * caller passes the session id explicitly so this works mounted under\n * any route shape. */\n function makeSessionHandler(\n direction: Direction | \"unregister\",\n ): NodeHandler {\n return async (req, res) => {\n setCorsHeaders(res);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"POST\") return json(res, 405, { error: \"method_not_allowed\" });\n const session = extractSession(req, prefix);\n if (!session) return json(res, 400, { error: \"missing_session\" });\n const token = getQuery(req).get(\"token\") ?? \"\";\n\n if (direction === \"unregister\") {\n const ok = broker.unregister(session, token);\n return json(res, ok ? 200 : 401, ok ? { ok: true } : { error: \"invalid_token\" });\n }\n\n let body: string;\n try { body = await readBody(req); } catch (e) {\n return json(res, 413, { error: e instanceof Error ? e.message : \"payload_error\" });\n }\n const ok = direction === \"inbound\"\n ? broker.inbox(session, token, body)\n : broker.outbox(session, token, body);\n return json(res, ok ? 200 : 401, ok ? { ok: true } : { error: \"invalid_token_or_frame\" });\n };\n }\n\n const inbox = makeSessionHandler(\"inbound\");\n const outbox = makeSessionHandler(\"outbound\");\n const unregister = makeSessionHandler(\"unregister\");\n\n const events: NodeHandler = async (req, res) => {\n setCorsHeaders(res);\n if (req.method === \"OPTIONS\") { res.statusCode = 204; return res.end(); }\n if (req.method !== \"GET\") return json(res, 405, { error: \"method_not_allowed\" });\n const session = extractSession(req, prefix);\n if (!session) return json(res, 400, { error: \"missing_session\" });\n const q = getQuery(req);\n const token = q.get(\"token\") ?? \"\";\n const direction = q.get(\"direction\") === \"outbound\" ? \"outbound\" : \"inbound\";\n\n const sub = broker.subscribe(session, token, direction);\n if (!sub.ok) {\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"text/event-stream\");\n res.write(`event: error\\ndata: ${sub.reason}\\n\\n`);\n return res.end();\n }\n\n res.statusCode = 200;\n res.setHeader(\"content-type\", \"text/event-stream\");\n res.setHeader(\"cache-control\", \"no-cache\");\n res.setHeader(\"connection\", \"keep-alive\");\n res.setHeader(\"x-accel-buffering\", \"no\");\n res.write(\"retry: 2000\\n\\n\");\n flush(res);\n\n let heartbeat: ReturnType<typeof setInterval> | null = setInterval(() => {\n res.write(\": keepalive\\n\\n\");\n flush(res);\n }, 15_000);\n if (heartbeat && typeof (heartbeat as { unref?: () => void }).unref === \"function\") {\n (heartbeat as { unref: () => void }).unref();\n }\n\n const cleanup = () => {\n if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }\n sub.unsubscribe();\n };\n req.on(\"close\", cleanup);\n req.on(\"error\", cleanup);\n\n try {\n for await (const frame of sub.frames) {\n res.write(`event: mcp\\ndata: ${frame}\\n\\n`);\n flush(res);\n }\n } catch {\n /* stream ended */\n } finally {\n cleanup();\n res.end();\n }\n };\n\n /**\n * Single handler — routes based on method + path. Useful for mounting\n * via `http.createServer(relay.handler)` without an Express layer.\n */\n const handler: NodeHandler = async (req, res) => {\n const pathname = getPathname(req);\n if (!pathname.startsWith(prefix + \"/\")) {\n return json(res, 404, { error: \"not_found\" });\n }\n const rest = pathname.slice(prefix.length); // \"/register\", \"/<id>/inbox\", etc.\n if (rest === \"/register\") return register(req, res);\n const m = /^\\/([A-Za-z0-9_-]{4,64})\\/(inbox|outbox|events|unregister)$/.exec(rest);\n if (!m) return json(res, 404, { error: \"not_found\" });\n const route = m[2];\n if (route === \"inbox\") return inbox(req, res);\n if (route === \"outbox\") return outbox(req, res);\n if (route === \"events\") return events(req, res);\n if (route === \"unregister\") return unregister(req, res);\n return json(res, 404, { error: \"not_found\" });\n };\n\n return { broker, handler, register, inbox, outbox, events, unregister };\n}\n\nfunction extractSession(req: IncomingMessage, prefix: string): string | null {\n const host = req.headers.host || \"x\";\n const u = new URL(req.url || \"/\", `http://${host}`);\n const path = u.pathname;\n if (prefix && !path.startsWith(prefix + \"/\")) return null;\n const rest = prefix ? path.slice(prefix.length) : path;\n const m = /^\\/([A-Za-z0-9_-]{4,64})\\//.exec(rest);\n return m ? m[1] : null;\n}\n\nfunction flush(res: ServerResponse) {\n // Node doesn't expose explicit flush, but write returns false when buffered;\n // explicit flushHeaders + write is enough for SSE in practice.\n const r = res as { flush?: () => void };\n r.flush?.();\n}\n\ntype Direction = \"inbound\" | \"outbound\";\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"relay-server.js"}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# Building agent-hookable demos on your site
|
|
2
|
+
|
|
3
|
+
End-to-end workflow for shipping a public-facing UI surface where visitors can
|
|
4
|
+
hand control to an MCP agent (Claude Code, Cursor, Claude Desktop, custom)
|
|
5
|
+
in real time. The piece you implement varies by site stack; the wire protocol
|
|
6
|
+
doesn't.
|
|
7
|
+
|
|
8
|
+
## The architecture, plainly
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌────────────────────────┐ ┌──────────────────────┐
|
|
12
|
+
│ Visitor's browser tab │ │ External agent │
|
|
13
|
+
│ ────────────────── │ │ (Claude Code, etc.) │
|
|
14
|
+
│ React app │ └────────┬─────────────┘
|
|
15
|
+
│ MicroMcpServer │ │
|
|
16
|
+
│ (tools touch the │ │ HTTP POST + SSE
|
|
17
|
+
│ live UI surface) │ │
|
|
18
|
+
└──────────┬─────────────┘ │
|
|
19
|
+
│ │
|
|
20
|
+
│ SSE stream │
|
|
21
|
+
│ + HTTP POST │
|
|
22
|
+
▼ ▼
|
|
23
|
+
┌──────────────────────────────────────┐
|
|
24
|
+
│ Relay broker │
|
|
25
|
+
│ ────────────── │
|
|
26
|
+
│ - Holds session→token mapping │
|
|
27
|
+
│ - Fans frames between subscribers │
|
|
28
|
+
│ - In-memory queues + sliding TTL │
|
|
29
|
+
│ - No tool logic, no state │
|
|
30
|
+
└──────────────────────────────────────┘
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Three pieces. You write zero of them yourself if you follow this guide:
|
|
34
|
+
|
|
35
|
+
1. **The browser-side MCP server.** Already shipped: `MicroMcpServer` + bridges +
|
|
36
|
+
`SseRelayTransport` from this package.
|
|
37
|
+
|
|
38
|
+
2. **The relay broker.** *Pick one.* Either the bundled Node server (see
|
|
39
|
+
[relay-server.md](./relay-server.md)) or a same-stack reference
|
|
40
|
+
implementation (see § "Same-stack relays" below — currently includes a
|
|
41
|
+
complete Laravel controller).
|
|
42
|
+
|
|
43
|
+
3. **The agent's client.** Out of your control — visitors paste your session
|
|
44
|
+
URL into whatever MCP client they already use.
|
|
45
|
+
|
|
46
|
+
## End-user UX
|
|
47
|
+
|
|
48
|
+
This is what visitors actually experience. Every demo follows the same shape:
|
|
49
|
+
|
|
50
|
+
1. Visitor opens `https://your-site.example/demos/some-surface`.
|
|
51
|
+
2. Surface is interactive on its own — clicking around works, the in-page
|
|
52
|
+
`MicroMcpServer` is already running.
|
|
53
|
+
3. Visitor clicks **Start share**.
|
|
54
|
+
4. The page mints a per-session token, registers it with the relay, and shows
|
|
55
|
+
a copyable share URL.
|
|
56
|
+
5. Visitor pastes the URL into their MCP client (`.mcp.json` for Claude Code,
|
|
57
|
+
Cursor's MCP settings, etc.). The client connects to the relay.
|
|
58
|
+
6. Agent calls tools → tools mutate the host page's React state → visitor
|
|
59
|
+
watches the surface change in real time. Optional: agent cursor + tool-call
|
|
60
|
+
feed render alongside.
|
|
61
|
+
7. **Stop share** tears the session down.
|
|
62
|
+
|
|
63
|
+
## Browser-side wiring (any site)
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import {
|
|
67
|
+
MicroMcpServer,
|
|
68
|
+
attachInProcess,
|
|
69
|
+
attachSseRelay,
|
|
70
|
+
createSessionDescriptor,
|
|
71
|
+
buildShareUrl,
|
|
72
|
+
textResult,
|
|
73
|
+
} from "@particle-academy/agent-integrations";
|
|
74
|
+
|
|
75
|
+
// Once per page mount:
|
|
76
|
+
const server = new MicroMcpServer({
|
|
77
|
+
info: { name: "your-demo", version: "0.1.0" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
server.registerTool(
|
|
81
|
+
{ name: "your_tool", description: "...", inputSchema: { /* JSON Schema */ } },
|
|
82
|
+
async (args) => {
|
|
83
|
+
// Touch React state, return a CallToolResult
|
|
84
|
+
return textResult("ok");
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
attachInProcess(server); // lets in-page UI also call tools
|
|
89
|
+
|
|
90
|
+
// When the user clicks "Start share":
|
|
91
|
+
async function startShare(relayBaseUrl: string) {
|
|
92
|
+
const desc = createSessionDescriptor();
|
|
93
|
+
await fetch(`${relayBaseUrl}/register`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: JSON.stringify({ session: desc.id, token: desc.token }),
|
|
97
|
+
});
|
|
98
|
+
attachSseRelay(server, {
|
|
99
|
+
baseUrl: relayBaseUrl,
|
|
100
|
+
sessionId: desc.id,
|
|
101
|
+
token: desc.token,
|
|
102
|
+
});
|
|
103
|
+
const url = buildShareUrl(window.location.origin + relayBaseUrl, desc);
|
|
104
|
+
// Show `url` to the user in a copy-able UI
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The components that bundle this pattern out of the box:
|
|
109
|
+
|
|
110
|
+
- `<SharedWhiteboard>` (subpath `./components/shared-whiteboard`) — whiteboard
|
|
111
|
+
+ share controls + agent panel + presence cursor in one drop-in.
|
|
112
|
+
- The pa-ux-sandbox repo has reference React components for composer, sheets,
|
|
113
|
+
flow, code-editor surfaces under `resources/js/react-demos/pages/*Agent*.tsx`.
|
|
114
|
+
|
|
115
|
+
## Relay broker — pick one
|
|
116
|
+
|
|
117
|
+
### Option A: bundled Node server (recommended for non-PHP hosts)
|
|
118
|
+
|
|
119
|
+
`@particle-academy/agent-integrations@^0.6.0` ships a complete Node HTTP
|
|
120
|
+
implementation, exposed three ways: standalone CLI, embeddable factory, and
|
|
121
|
+
Dockerfile. **Full docs:** [relay-server.md](./relay-server.md).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx -p @particle-academy/agent-integrations agent-integrations-relay --port 8787
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Point your demo's `relayBaseUrl` at this server's origin and you're done.
|
|
128
|
+
|
|
129
|
+
### Option B: same-stack reference implementation
|
|
130
|
+
|
|
131
|
+
If your site already runs Laravel/Rails/Django/etc., implementing the relay
|
|
132
|
+
in the same stack avoids the operational cost of a separate Node process. The
|
|
133
|
+
wire protocol is small (~5 endpoints); a port is ~200 LOC.
|
|
134
|
+
|
|
135
|
+
A complete **Laravel 10+** reference follows. It's framework-agnostic in
|
|
136
|
+
intent — adapt the route registration and cache binding to your framework's
|
|
137
|
+
conventions; the logic is the same shape everywhere.
|
|
138
|
+
|
|
139
|
+
#### Laravel reference implementation
|
|
140
|
+
|
|
141
|
+
**1. Controller** — drop into `app/Http/Controllers/McpRelayController.php`:
|
|
142
|
+
|
|
143
|
+
```php
|
|
144
|
+
<?php
|
|
145
|
+
|
|
146
|
+
namespace App\Http\Controllers;
|
|
147
|
+
|
|
148
|
+
use Illuminate\Http\JsonResponse;
|
|
149
|
+
use Illuminate\Http\Request;
|
|
150
|
+
use Illuminate\Support\Facades\Cache;
|
|
151
|
+
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
152
|
+
|
|
153
|
+
class McpRelayController extends Controller
|
|
154
|
+
{
|
|
155
|
+
private const TTL_SECONDS = 14400; // 4h — refreshed on every authenticated touch.
|
|
156
|
+
private const POLL_INTERVAL_MS = 200;
|
|
157
|
+
|
|
158
|
+
public function register(Request $request): JsonResponse
|
|
159
|
+
{
|
|
160
|
+
$data = $request->validate([
|
|
161
|
+
'session' => ['required', 'string', 'regex:/^[A-Za-z0-9_-]{4,64}$/'],
|
|
162
|
+
'token' => ['required', 'string', 'min:16', 'max:128'],
|
|
163
|
+
]);
|
|
164
|
+
Cache::put($this->tokenKey($data['session']), hash('sha256', $data['token']), self::TTL_SECONDS);
|
|
165
|
+
return response()->json(['ok' => true]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public function unregister(Request $request, string $session): JsonResponse
|
|
169
|
+
{
|
|
170
|
+
if (! $this->validateToken($session, (string) $request->query('token'))) {
|
|
171
|
+
return response()->json(['error' => 'invalid_token'], 401);
|
|
172
|
+
}
|
|
173
|
+
Cache::forget($this->tokenKey($session));
|
|
174
|
+
return response()->json(['ok' => true]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public function inbox(Request $request, string $session): JsonResponse
|
|
178
|
+
{
|
|
179
|
+
if (! $this->validateToken($session, (string) $request->query('token'))) {
|
|
180
|
+
return response()->json(['error' => 'invalid_token'], 401);
|
|
181
|
+
}
|
|
182
|
+
$payload = $request->getContent();
|
|
183
|
+
if ($payload === '' || ! str_contains($payload, '"jsonrpc"')) {
|
|
184
|
+
return response()->json(['error' => 'invalid_frame'], 400);
|
|
185
|
+
}
|
|
186
|
+
$this->fanOut($session, 'inbound', $payload);
|
|
187
|
+
return response()->json(['ok' => true]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
public function outbox(Request $request, string $session): JsonResponse
|
|
191
|
+
{
|
|
192
|
+
if (! $this->validateToken($session, (string) $request->query('token'))) {
|
|
193
|
+
return response()->json(['error' => 'invalid_token'], 401);
|
|
194
|
+
}
|
|
195
|
+
$payload = $request->getContent();
|
|
196
|
+
if ($payload === '' || ! str_contains($payload, '"jsonrpc"')) {
|
|
197
|
+
return response()->json(['error' => 'invalid_frame'], 400);
|
|
198
|
+
}
|
|
199
|
+
$this->fanOut($session, 'outbound', $payload);
|
|
200
|
+
return response()->json(['ok' => true]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
public function events(Request $request, string $session): StreamedResponse
|
|
204
|
+
{
|
|
205
|
+
$token = (string) $request->query('token');
|
|
206
|
+
if (! $this->validateToken($session, $token)) {
|
|
207
|
+
return response()->stream(
|
|
208
|
+
fn () => print "event: error\ndata: invalid_token\n\n",
|
|
209
|
+
401,
|
|
210
|
+
['content-type' => 'text/event-stream'],
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
$direction = $request->query('direction', 'inbound') === 'outbound' ? 'outbound' : 'inbound';
|
|
214
|
+
$subscriberId = bin2hex(random_bytes(8));
|
|
215
|
+
|
|
216
|
+
return response()->stream(function () use ($session, $direction, $subscriberId) {
|
|
217
|
+
@set_time_limit(0);
|
|
218
|
+
@ini_set('output_buffering', 'off');
|
|
219
|
+
@ini_set('zlib.output_compression', '0');
|
|
220
|
+
|
|
221
|
+
$key = $this->queueKey($session, $direction, $subscriberId);
|
|
222
|
+
$subsKey = $this->subscribersKey($session, $direction);
|
|
223
|
+
$subs = Cache::get($subsKey, []);
|
|
224
|
+
$subs[$subscriberId] = time();
|
|
225
|
+
Cache::put($subsKey, $subs, self::TTL_SECONDS);
|
|
226
|
+
|
|
227
|
+
if ($direction === 'outbound') {
|
|
228
|
+
$this->fanOut($session, 'inbound', json_encode([
|
|
229
|
+
'jsonrpc' => '2.0',
|
|
230
|
+
'method' => 'notifications/peer_joined',
|
|
231
|
+
'params' => ['subscriberId' => $subscriberId, 'ts' => time() * 1000],
|
|
232
|
+
]));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
echo "retry: 2000\n\n";
|
|
236
|
+
$this->flush();
|
|
237
|
+
|
|
238
|
+
$lastBeat = time();
|
|
239
|
+
while (! connection_aborted()) {
|
|
240
|
+
$frames = Cache::pull($key, []);
|
|
241
|
+
foreach ($frames as $frame) {
|
|
242
|
+
echo "event: mcp\ndata: {$frame}\n\n";
|
|
243
|
+
}
|
|
244
|
+
if (! empty($frames)) {
|
|
245
|
+
$this->flush();
|
|
246
|
+
}
|
|
247
|
+
if ((time() - $lastBeat) >= 15) {
|
|
248
|
+
echo ": keepalive\n\n";
|
|
249
|
+
$this->flush();
|
|
250
|
+
$lastBeat = time();
|
|
251
|
+
}
|
|
252
|
+
usleep(self::POLL_INTERVAL_MS * 1000);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
$subs = Cache::get($subsKey, []);
|
|
256
|
+
unset($subs[$subscriberId]);
|
|
257
|
+
Cache::put($subsKey, $subs, self::TTL_SECONDS);
|
|
258
|
+
Cache::forget($key);
|
|
259
|
+
|
|
260
|
+
if ($direction === 'outbound') {
|
|
261
|
+
$this->fanOut($session, 'inbound', json_encode([
|
|
262
|
+
'jsonrpc' => '2.0',
|
|
263
|
+
'method' => 'notifications/peer_left',
|
|
264
|
+
'params' => ['subscriberId' => $subscriberId, 'ts' => time() * 1000],
|
|
265
|
+
]));
|
|
266
|
+
}
|
|
267
|
+
}, 200, [
|
|
268
|
+
'content-type' => 'text/event-stream',
|
|
269
|
+
'cache-control' => 'no-cache',
|
|
270
|
+
'x-accel-buffering' => 'no',
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private function fanOut(string $session, string $direction, string $payload): void
|
|
275
|
+
{
|
|
276
|
+
$subsKey = $this->subscribersKey($session, $direction);
|
|
277
|
+
$subs = Cache::get($subsKey, []);
|
|
278
|
+
foreach (array_keys($subs) as $subscriberId) {
|
|
279
|
+
$key = $this->queueKey($session, $direction, $subscriberId);
|
|
280
|
+
$existing = Cache::get($key, []);
|
|
281
|
+
$existing[] = $payload;
|
|
282
|
+
Cache::put($key, $existing, self::TTL_SECONDS);
|
|
283
|
+
}
|
|
284
|
+
Cache::put($subsKey, $subs, self::TTL_SECONDS);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private function validateToken(string $session, string $token): bool
|
|
288
|
+
{
|
|
289
|
+
if ($session === '' || $token === '') return false;
|
|
290
|
+
$key = $this->tokenKey($session);
|
|
291
|
+
$stored = Cache::get($key);
|
|
292
|
+
if ($stored === null) return false;
|
|
293
|
+
if (! hash_equals((string) $stored, hash('sha256', $token))) return false;
|
|
294
|
+
Cache::put($key, $stored, self::TTL_SECONDS);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private function tokenKey(string $session): string
|
|
299
|
+
{
|
|
300
|
+
return "mcp-relay:token:{$session}";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private function subscribersKey(string $session, string $direction): string
|
|
304
|
+
{
|
|
305
|
+
return "mcp-relay:subs:{$session}:{$direction}";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private function queueKey(string $session, string $direction, string $subscriberId): string
|
|
309
|
+
{
|
|
310
|
+
return "mcp-relay:queue:{$session}:{$direction}:{$subscriberId}";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private function flush(): void
|
|
314
|
+
{
|
|
315
|
+
if (function_exists('ob_get_level') && ob_get_level() > 0) {
|
|
316
|
+
@ob_flush();
|
|
317
|
+
}
|
|
318
|
+
@flush();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**2. Routes** — `routes/web.php`:
|
|
324
|
+
|
|
325
|
+
```php
|
|
326
|
+
Route::post('/mcp-relay/register', [McpRelayController::class, 'register']);
|
|
327
|
+
Route::post('/mcp-relay/{session}/unregister', [McpRelayController::class, 'unregister']);
|
|
328
|
+
Route::post('/mcp-relay/{session}/inbox', [McpRelayController::class, 'inbox']);
|
|
329
|
+
Route::post('/mcp-relay/{session}/outbox', [McpRelayController::class, 'outbox']);
|
|
330
|
+
Route::get ('/mcp-relay/{session}/events', [McpRelayController::class, 'events']);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**3. CSRF exemption** — `bootstrap/app.php` (Laravel 11+):
|
|
334
|
+
|
|
335
|
+
```php
|
|
336
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
337
|
+
$middleware->validateCsrfTokens(except: [
|
|
338
|
+
'mcp-relay/*',
|
|
339
|
+
]);
|
|
340
|
+
})
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
(Laravel 10: add to `VerifyCsrfToken::$except` in `app/Http/Middleware/`.)
|
|
344
|
+
|
|
345
|
+
**4. Operational notes**
|
|
346
|
+
|
|
347
|
+
- Storage uses the default cache driver — `file` works for single-server
|
|
348
|
+
setups, but for any production deploy use `redis` so the broker survives
|
|
349
|
+
PHP-FPM worker recycles and load-balances correctly across processes.
|
|
350
|
+
- The SSE `events()` action holds a long-lived HTTP connection. Confirm your
|
|
351
|
+
proxy / PHP-FPM timeouts allow this:
|
|
352
|
+
- Nginx: `proxy_read_timeout` ≥ 6h, `proxy_buffering off` for SSE
|
|
353
|
+
- PHP-FPM: `request_terminate_timeout = 0` or `>= 4h`
|
|
354
|
+
- The 15-second keepalive ping (`: keepalive\n\n`) prevents most proxies from
|
|
355
|
+
killing idle connections. Validate after deploy.
|
|
356
|
+
- This implementation handles ~50 concurrent sessions on a single Laravel
|
|
357
|
+
worker. For more, run multiple workers behind a load balancer with a
|
|
358
|
+
redis cache backend.
|
|
359
|
+
|
|
360
|
+
### Option C: implement in another stack
|
|
361
|
+
|
|
362
|
+
Same protocol, same five endpoints, same wire format. Reference the Laravel
|
|
363
|
+
controller above and the [relay-protocol.md](./relay-protocol.md) wire spec.
|
|
364
|
+
For Rails: `ActionController::Live` for SSE + `Rails.cache` for storage. For
|
|
365
|
+
Django: `StreamingHttpResponse` + `django.core.cache`. For Express/Fastify:
|
|
366
|
+
just use the bundled `createNodeRelay()` factory from this package.
|
|
367
|
+
|
|
368
|
+
## Choosing between A and B
|
|
369
|
+
|
|
370
|
+
| You want… | Pick |
|
|
371
|
+
|---|---|
|
|
372
|
+
| Add a couple of demos to an existing Laravel app | **B (Laravel)** — no new infrastructure |
|
|
373
|
+
| Demo site is static / no backend yet | **A (Node)** — `agent-integrations-relay` container |
|
|
374
|
+
| Multiple Fancy UI demos across multiple sites | **A**, deployed once at a central origin |
|
|
375
|
+
| Already running Rails/Django/etc. | **C** — port the Laravel reference |
|
|
376
|
+
| Edge / serverless | **A** with `createNodeRelay` adapted to your runtime, or bring your own `RelayBroker` + `Store` |
|
|
377
|
+
|
|
378
|
+
The browser-side code doesn't change between options. The relay broker URL is
|
|
379
|
+
the only difference.
|
|
380
|
+
|
|
381
|
+
## Worked example — the particle.academy site
|
|
382
|
+
|
|
383
|
+
The marketing site for this kit hosts two agent-hookable demos at
|
|
384
|
+
`/ui/demos/composer` and `/ui/demos/agent-presence`. Stack: Laravel 12 +
|
|
385
|
+
Livewire + Tailwind v4, plus a React island for the demo surfaces. The relay
|
|
386
|
+
runs in-app via Option B (the Laravel reference above lives at
|
|
387
|
+
`app/Http/Controllers/McpRelayController.php` in the
|
|
388
|
+
[Particle-Academy/website](https://github.com/Particle-Academy/website) repo).
|
|
389
|
+
|
|
390
|
+
The pattern there matches this doc exactly: a controlled React component
|
|
391
|
+
mounts via `data-fancy-demo` placeholders; `MicroMcpServer` registers
|
|
392
|
+
composer/whiteboard tools that touch local state; clicking *Start share*
|
|
393
|
+
hits the relay's `/register` and opens an SSE subscription. Visitors paste
|
|
394
|
+
the resulting URL into their MCP client and the demo becomes agent-driven.
|
|
395
|
+
|
|
396
|
+
## See also
|
|
397
|
+
|
|
398
|
+
- [relay-protocol.md](./relay-protocol.md) — the wire format, including the
|
|
399
|
+
three transports the protocol supports (Reverb, WebRTC, SSE+POST)
|
|
400
|
+
- [relay-server.md](./relay-server.md) — the bundled Node relay
|
|
401
|
+
- [`SharedWhiteboard`](../src/components/SharedWhiteboard/SharedWhiteboard.tsx) —
|
|
402
|
+
source for a fully-composed agent-hookable surface, useful as a template
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Relay server
|
|
2
|
+
|
|
3
|
+
Ships with `@particle-academy/agent-integrations` as of `0.6.0`. The relay is the
|
|
4
|
+
server-side half of the SSE+POST tunnel documented in [relay-protocol.md](./relay-protocol.md) —
|
|
5
|
+
it shuttles JSON-RPC frames between a browser-hosted `MicroMcpServer` and any
|
|
6
|
+
external MCP client (Claude Code, Cursor, Claude Desktop, custom agents).
|
|
7
|
+
|
|
8
|
+
The browser is the *server* in this model — it owns the tools and the state.
|
|
9
|
+
The relay is purely a broker. No tools run server-side; no state persists
|
|
10
|
+
across restarts.
|
|
11
|
+
|
|
12
|
+
## When you need a relay
|
|
13
|
+
|
|
14
|
+
- **In-process agents** (an AI assistant rendered inside the same React tree)
|
|
15
|
+
don't need a relay — use `attachInProcess(server)` directly. The relay is for
|
|
16
|
+
*external* agents whose process can't reach the browser tab.
|
|
17
|
+
- **End-user-facing demos** where a visitor pastes a session URL into Claude
|
|
18
|
+
Code → the relay is hosted somewhere reachable from both the browser and the
|
|
19
|
+
agent's machine.
|
|
20
|
+
|
|
21
|
+
## Three ways to run it
|
|
22
|
+
|
|
23
|
+
### 1. `npx` — local dev / one-off prod
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx -p @particle-academy/agent-integrations agent-integrations-relay --port 8787
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
End-to-end smoke test:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
curl http://localhost:8787/ # health
|
|
33
|
+
curl -X POST -H 'content-type: application/json' \
|
|
34
|
+
-d '{"session":"demo-001","token":"abcdef0123456789abcdef0123456789"}' \
|
|
35
|
+
http://localhost:8787/register
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
CLI flags (or matching env vars `PORT`, `HOST`, `PREFIX`, `TTL_MS`, `CORS_ALLOW_ORIGIN`):
|
|
39
|
+
|
|
40
|
+
| Flag | Default | What |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `--port <n>` | `8787` | Listen port. |
|
|
43
|
+
| `--host <addr>` | `0.0.0.0` | Bind address. |
|
|
44
|
+
| `--prefix <path>` | `""` | URL path prefix (e.g. `/mcp-relay`) when behind a reverse proxy. |
|
|
45
|
+
| `--ttl-ms <n>` | `14_400_000` (4h) | Session inactivity timeout. |
|
|
46
|
+
| `--cors <origin>` | `*` | `Access-Control-Allow-Origin` header value. |
|
|
47
|
+
|
|
48
|
+
### 2. Embed in an existing Node HTTP framework
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createNodeRelay } from "@particle-academy/agent-integrations/relay-server";
|
|
52
|
+
|
|
53
|
+
const relay = createNodeRelay({ pathPrefix: "/mcp-relay", corsAllowOrigin: "*" });
|
|
54
|
+
|
|
55
|
+
app.post("/mcp-relay/register", (req, res) => relay.register(req, res));
|
|
56
|
+
app.post("/mcp-relay/:s/inbox", (req, res) => relay.inbox(req, res));
|
|
57
|
+
app.post("/mcp-relay/:s/outbox", (req, res) => relay.outbox(req, res));
|
|
58
|
+
app.get ("/mcp-relay/:s/events", (req, res) => relay.events(req, res));
|
|
59
|
+
app.post("/mcp-relay/:s/unregister", (req, res) => relay.unregister(req, res));
|
|
60
|
+
|
|
61
|
+
// Or a single fall-through handler for routers that don't need per-route control:
|
|
62
|
+
app.use("/mcp-relay", (req, res) => relay.handler(req, res));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. Docker
|
|
66
|
+
|
|
67
|
+
A `Dockerfile` ships in the package. Build + run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/Particle-Academy/agent-integrations
|
|
71
|
+
cd agent-integrations
|
|
72
|
+
npm install
|
|
73
|
+
npm run build
|
|
74
|
+
docker build -t agent-integrations-relay .
|
|
75
|
+
docker run -p 8787:8787 agent-integrations-relay
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Deploy targets that just want a container:
|
|
79
|
+
|
|
80
|
+
- **Fly.io:** `fly launch --image agent-integrations-relay --internal-port 8787`
|
|
81
|
+
- **Railway:** `railway up` after committing the Dockerfile
|
|
82
|
+
- **Render:** point a Web Service at the Dockerfile, expose 8787
|
|
83
|
+
- **Cloud Run:** `gcloud run deploy --image agent-integrations-relay --port 8787 --allow-unauthenticated`
|
|
84
|
+
|
|
85
|
+
## Wire protocol
|
|
86
|
+
|
|
87
|
+
Same shape every consumer expects:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
POST {prefix}/register body: { session, token } → { ok }
|
|
91
|
+
POST {prefix}/{session}/inbox?token=… body: JSON-RPC frame → { ok }
|
|
92
|
+
POST {prefix}/{session}/outbox?token=… body: JSON-RPC frame → { ok }
|
|
93
|
+
GET {prefix}/{session}/events?token=…&direction=inbound|outbound
|
|
94
|
+
SSE stream of `event: mcp\ndata: …\n\n`
|
|
95
|
+
POST {prefix}/{session}/unregister?token=… → { ok }
|
|
96
|
+
GET {prefix}/ healthcheck → 200
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The browser opens an `inbound` SSE subscription; external agents open `outbound`.
|
|
100
|
+
Both POST JSON-RPC frames at their own direction's inbox/outbox.
|
|
101
|
+
|
|
102
|
+
## Replacing the in-memory store
|
|
103
|
+
|
|
104
|
+
The default broker holds session state in a `Map`. To run multiple relay
|
|
105
|
+
processes behind a load balancer, swap the store:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { RelayBroker, type Store } from "@particle-academy/agent-integrations/relay-server";
|
|
109
|
+
|
|
110
|
+
class RedisStore implements Store { /* ... */ }
|
|
111
|
+
|
|
112
|
+
const broker = new RelayBroker({ store: new RedisStore(/* … */) });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Frame fan-out within a single process is still in-memory; for multi-instance
|
|
116
|
+
correctness wire frames through a pub/sub (Redis Streams, NATS, etc.) by
|
|
117
|
+
extending the broker or running an instance per session-id-prefix.
|
|
118
|
+
|
|
119
|
+
## Security notes
|
|
120
|
+
|
|
121
|
+
- **Token comparison is timing-safe** (`crypto.timingSafeEqual`).
|
|
122
|
+
- **Sessions auto-expire** after `ttlMs` inactivity; every authenticated touch
|
|
123
|
+
slides the TTL forward.
|
|
124
|
+
- **Payload caps** — individual frames are rejected past 256 KB.
|
|
125
|
+
- The relay carries opaque frames; auth is your session token. Tighter access
|
|
126
|
+
control (per-IP rate limit, allowlist) belongs in your reverse proxy layer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/agent-integrations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "MCP-driven agent presence in collab sessions: per-session micro-MCP server, pluggable bridges to fancy-* packages, and agent UX components (panel + on-canvas cursor).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"main": "./dist/index.cjs",
|
|
13
13
|
"module": "./dist/index.js",
|
|
14
14
|
"types": "./dist/index.d.ts",
|
|
15
|
+
"bin": {
|
|
16
|
+
"agent-integrations-relay": "./dist/relay-server-cli.js",
|
|
17
|
+
"ai-relay": "./dist/relay-server-cli.js"
|
|
18
|
+
},
|
|
15
19
|
"exports": {
|
|
16
20
|
".": {
|
|
17
21
|
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
@@ -73,6 +77,10 @@
|
|
|
73
77
|
"import": { "types": "./dist/sharing/index.d.ts", "default": "./dist/sharing.js" },
|
|
74
78
|
"require": { "types": "./dist/sharing/index.d.cts", "default": "./dist/sharing.cjs" }
|
|
75
79
|
},
|
|
80
|
+
"./relay-server": {
|
|
81
|
+
"import": { "types": "./dist/relay-server/index.d.ts", "default": "./dist/relay-server.js" },
|
|
82
|
+
"require": { "types": "./dist/relay-server/index.d.cts", "default": "./dist/relay-server.cjs" }
|
|
83
|
+
},
|
|
76
84
|
"./styles.css": "./dist/styles.css"
|
|
77
85
|
},
|
|
78
86
|
"files": ["dist", "docs", "README.md"],
|
|
@@ -98,6 +106,7 @@
|
|
|
98
106
|
},
|
|
99
107
|
"devDependencies": {
|
|
100
108
|
"@particle-academy/fancy-whiteboard": "^0.1.5",
|
|
109
|
+
"@types/node": "^22.0.0",
|
|
101
110
|
"@types/react": "^19.0.0",
|
|
102
111
|
"@types/react-dom": "^19.0.0",
|
|
103
112
|
"react": "^19.0.0",
|