@vincentwei1021/synapse-openclaw-plugin 0.5.0

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.
@@ -0,0 +1,144 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+
4
+ export type McpClientStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
5
+
6
+ export interface SynapseMcpClientOptions {
7
+ synapseUrl: string;
8
+ apiKey: string;
9
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
10
+ }
11
+
12
+ /**
13
+ * Wraps @modelcontextprotocol/sdk Client with:
14
+ * - Lazy connection (connect on first callTool)
15
+ * - Auto-reconnect on session expiry (404)
16
+ * - Status tracking
17
+ * - Graceful disconnect
18
+ */
19
+ export class SynapseMcpClient {
20
+ private client: Client | null = null;
21
+ private transport: StreamableHTTPClientTransport | null = null;
22
+ private _status: McpClientStatus = "disconnected";
23
+ private readonly opts: SynapseMcpClientOptions;
24
+
25
+ constructor(opts: SynapseMcpClientOptions) {
26
+ this.opts = opts;
27
+ }
28
+
29
+ get status(): McpClientStatus {
30
+ return this._status;
31
+ }
32
+
33
+ /** Establish MCP connection. Called lazily on first callTool. */
34
+ async connect(): Promise<void> {
35
+ if (this._status === "connected" && this.client) return;
36
+
37
+ this._status = "connecting";
38
+ try {
39
+ this.transport = new StreamableHTTPClientTransport(
40
+ new URL("/api/mcp", this.opts.synapseUrl),
41
+ {
42
+ requestInit: {
43
+ headers: {
44
+ Authorization: `Bearer ${this.opts.apiKey}`,
45
+ },
46
+ },
47
+ }
48
+ );
49
+
50
+ this.client = new Client({
51
+ name: "openclaw-synapse",
52
+ version: "0.1.0",
53
+ });
54
+
55
+ await this.client.connect(this.transport);
56
+ this._status = "connected";
57
+ this.opts.logger.info("MCP connection established");
58
+ } catch (err) {
59
+ this._status = "disconnected";
60
+ await this.cleanupConnection();
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Call a Synapse MCP tool. Handles lazy connection and auto-reconnect.
67
+ * Returns parsed JSON from the first text content block.
68
+ */
69
+ async callTool(name: string, args: Record<string, unknown> = {}): Promise<unknown> {
70
+ // Lazy connect
71
+ if (!this.client || this._status !== "connected") {
72
+ await this.connect();
73
+ }
74
+
75
+ try {
76
+ return await this._doCallTool(name, args);
77
+ } catch (err: unknown) {
78
+ // Session expired (404) or connection lost — reconnect and retry once
79
+ if (this.isSessionExpiredError(err)) {
80
+ this.opts.logger.warn("MCP session expired, reconnecting...");
81
+ this._status = "reconnecting";
82
+ await this.cleanupConnection();
83
+ await this.connect();
84
+ return await this._doCallTool(name, args);
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ /** Graceful disconnect. */
91
+ async disconnect(): Promise<void> {
92
+ await this.cleanupConnection();
93
+ this._status = "disconnected";
94
+ this.opts.logger.info("MCP connection closed");
95
+ }
96
+
97
+ private async cleanupConnection(): Promise<void> {
98
+ if (this.client) {
99
+ try {
100
+ await this.client.close();
101
+ } catch {
102
+ // Ignore close errors during cleanup
103
+ }
104
+ }
105
+
106
+ this.client = null;
107
+ this.transport = null;
108
+ }
109
+
110
+ private async _doCallTool(name: string, args: Record<string, unknown>): Promise<unknown> {
111
+ if (!this.client) throw new Error("MCP client not connected");
112
+
113
+ const result = await this.client.callTool({ name, arguments: args });
114
+
115
+ if (result.isError) {
116
+ const errorText = (result.content as Array<{ type: string; text?: string }>)
117
+ .filter((c) => c.type === "text")
118
+ .map((c) => c.text)
119
+ .join("\n");
120
+ throw new Error(`Synapse MCP tool error (${name}): ${errorText}`);
121
+ }
122
+
123
+ // Parse first text content block as JSON
124
+ const textContent = (result.content as Array<{ type: string; text?: string }>).find(
125
+ (c) => c.type === "text"
126
+ );
127
+ if (!textContent?.text) return null;
128
+
129
+ try {
130
+ return JSON.parse(textContent.text);
131
+ } catch {
132
+ // Return raw text if not valid JSON
133
+ return textContent.text;
134
+ }
135
+ }
136
+
137
+ private isSessionExpiredError(err: unknown): boolean {
138
+ if (err instanceof Error) {
139
+ const msg = err.message.toLowerCase();
140
+ return msg.includes("404") || msg.includes("session expired") || msg.includes("session not found");
141
+ }
142
+ return false;
143
+ }
144
+ }
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { SynapseSseListener } from "./sse-listener.js";
3
+
4
+ function createLogger() {
5
+ return {
6
+ info: vi.fn(),
7
+ warn: vi.fn(),
8
+ error: vi.fn(),
9
+ };
10
+ }
11
+
12
+ function createSseResponse(chunks: string[]) {
13
+ const encoder = new TextEncoder();
14
+ const body = new ReadableStream<Uint8Array>({
15
+ start(controller) {
16
+ for (const chunk of chunks) {
17
+ controller.enqueue(encoder.encode(chunk));
18
+ }
19
+ controller.close();
20
+ },
21
+ });
22
+
23
+ return new Response(body, {
24
+ status: 200,
25
+ headers: { "content-type": "text/event-stream" },
26
+ });
27
+ }
28
+
29
+ async function flushMicrotasks() {
30
+ await Promise.resolve();
31
+ await Promise.resolve();
32
+ }
33
+
34
+ describe("SynapseSseListener", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ vi.useFakeTimers();
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.useRealTimers();
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it("parses SSE data events and forwards them to onEvent", async () => {
46
+ const logger = createLogger();
47
+ const onEvent = vi.fn();
48
+ const onReconnect = vi.fn().mockResolvedValue(undefined);
49
+
50
+ const fetchMock = vi.fn().mockResolvedValue(
51
+ createSseResponse([
52
+ ": heartbeat\n\n",
53
+ "data: {\"type\":\"new_notification\",\"notificationUuid\":\"notification-1\"}\n\n",
54
+ ]),
55
+ );
56
+ vi.stubGlobal("fetch", fetchMock);
57
+
58
+ const listener = new SynapseSseListener({
59
+ synapseUrl: "https://synapse.example.com/",
60
+ apiKey: "syn_test_key",
61
+ onEvent,
62
+ onReconnect,
63
+ logger,
64
+ });
65
+
66
+ await listener.connect();
67
+ await flushMicrotasks();
68
+
69
+ expect(fetchMock).toHaveBeenCalledWith(
70
+ "https://synapse.example.com/api/events/notifications",
71
+ expect.objectContaining({
72
+ headers: {
73
+ Authorization: "Bearer syn_test_key",
74
+ Accept: "text/event-stream",
75
+ },
76
+ }),
77
+ );
78
+ expect(onEvent).toHaveBeenCalledWith({
79
+ type: "new_notification",
80
+ notificationUuid: "notification-1",
81
+ });
82
+ expect(listener.status).toBe("reconnecting");
83
+ expect(logger.info).toHaveBeenCalledWith("[Synapse] SSE connection established");
84
+
85
+ listener.disconnect();
86
+ });
87
+
88
+ it("reconnects after an initial fetch failure and runs onReconnect once connected", async () => {
89
+ const logger = createLogger();
90
+ const onEvent = vi.fn();
91
+ const onReconnect = vi.fn().mockResolvedValue(undefined);
92
+
93
+ const fetchMock = vi.fn()
94
+ .mockRejectedValueOnce(new Error("network down"))
95
+ .mockResolvedValueOnce(createSseResponse(["data: {\"type\":\"count_update\",\"unreadCount\":2}\n\n"]));
96
+ vi.stubGlobal("fetch", fetchMock);
97
+
98
+ const listener = new SynapseSseListener({
99
+ synapseUrl: "https://synapse.example.com",
100
+ apiKey: "syn_test_key",
101
+ onEvent,
102
+ onReconnect,
103
+ logger,
104
+ });
105
+
106
+ await listener.connect();
107
+ expect(listener.status).toBe("reconnecting");
108
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("SSE connection failed"));
109
+ expect(logger.info).toHaveBeenCalledWith("SSE reconnecting in 1000ms");
110
+
111
+ await vi.advanceTimersByTimeAsync(1000);
112
+ await flushMicrotasks();
113
+
114
+ expect(fetchMock).toHaveBeenCalledTimes(2);
115
+ expect(onReconnect).toHaveBeenCalledTimes(1);
116
+ expect(onEvent).toHaveBeenCalledWith({
117
+ type: "count_update",
118
+ unreadCount: 2,
119
+ });
120
+
121
+ listener.disconnect();
122
+ });
123
+
124
+ it("warns on malformed JSON payloads without crashing the listener", async () => {
125
+ const logger = createLogger();
126
+ const onEvent = vi.fn();
127
+ const onReconnect = vi.fn().mockResolvedValue(undefined);
128
+
129
+ vi.stubGlobal(
130
+ "fetch",
131
+ vi.fn().mockResolvedValue(createSseResponse(["data: not-json\n\n"])),
132
+ );
133
+
134
+ const listener = new SynapseSseListener({
135
+ synapseUrl: "https://synapse.example.com",
136
+ apiKey: "syn_test_key",
137
+ onEvent,
138
+ onReconnect,
139
+ logger,
140
+ });
141
+
142
+ await listener.connect();
143
+ await flushMicrotasks();
144
+
145
+ expect(onEvent).not.toHaveBeenCalled();
146
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("SSE JSON parse error"));
147
+
148
+ listener.disconnect();
149
+ });
150
+ });
@@ -0,0 +1,184 @@
1
+ export type SseListenerStatus = "connected" | "disconnected" | "reconnecting";
2
+
3
+ export interface SseNotificationEvent {
4
+ type: string; // "new_notification"
5
+ notificationUuid?: string;
6
+ notificationType?: string; // "task_assigned", "mentioned", etc.
7
+ unreadCount?: number;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export interface SynapseSseListenerOptions {
12
+ synapseUrl: string;
13
+ apiKey: string;
14
+ onEvent: (event: SseNotificationEvent) => void;
15
+ onReconnect: () => Promise<void>;
16
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
17
+ }
18
+
19
+ const INITIAL_DELAY_MS = 1_000;
20
+ const MAX_DELAY_MS = 30_000;
21
+
22
+ export class SynapseSseListener {
23
+ private readonly opts: SynapseSseListenerOptions;
24
+ private _status: SseListenerStatus = "disconnected";
25
+ private abortController: AbortController | null = null;
26
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
27
+ private reconnectDelay = INITIAL_DELAY_MS;
28
+
29
+ constructor(opts: SynapseSseListenerOptions) {
30
+ this.opts = opts;
31
+ }
32
+
33
+ get status(): SseListenerStatus {
34
+ return this._status;
35
+ }
36
+
37
+ /** Start the SSE connection. Resolves once the first bytes arrive (or rejects on immediate failure). */
38
+ async connect(): Promise<void> {
39
+ this.clearReconnectTimer();
40
+
41
+ const abortController = new AbortController();
42
+ this.abortController = abortController;
43
+
44
+ const url = `${this.opts.synapseUrl.replace(/\/$/, "")}/api/events/notifications`;
45
+
46
+ let response: Response;
47
+ try {
48
+ response = await fetch(url, {
49
+ headers: {
50
+ Authorization: `Bearer ${this.opts.apiKey}`,
51
+ Accept: "text/event-stream",
52
+ },
53
+ signal: abortController.signal,
54
+ });
55
+ } catch (err) {
56
+ if (abortController.signal.aborted) return; // intentional disconnect
57
+ this.opts.logger.error(`SSE connection failed: ${err}`);
58
+ this.scheduleReconnect();
59
+ return;
60
+ }
61
+
62
+ if (!response.ok) {
63
+ this.opts.logger.error(`SSE endpoint returned ${response.status}`);
64
+ this.scheduleReconnect();
65
+ return;
66
+ }
67
+
68
+ if (!response.body) {
69
+ this.opts.logger.error("SSE response has no body");
70
+ this.scheduleReconnect();
71
+ return;
72
+ }
73
+
74
+ // Connection succeeded — reset backoff
75
+ const isReconnect = this._status === "reconnecting";
76
+ this._status = "connected";
77
+ this.reconnectDelay = INITIAL_DELAY_MS;
78
+ this.opts.logger.info("[Synapse] SSE connection established");
79
+
80
+ if (isReconnect) {
81
+ // Fire onReconnect callback so the caller can back-fill missed notifications
82
+ try {
83
+ await this.opts.onReconnect();
84
+ } catch (err) {
85
+ this.opts.logger.warn(`onReconnect callback error: ${err}`);
86
+ }
87
+ }
88
+
89
+ // Read the stream
90
+ this.consumeStream(response.body, abortController.signal);
91
+ }
92
+
93
+ /** Gracefully close the SSE connection. */
94
+ disconnect(): void {
95
+ this.clearReconnectTimer();
96
+ if (this.abortController) {
97
+ this.abortController.abort();
98
+ this.abortController = null;
99
+ }
100
+ this._status = "disconnected";
101
+ this.opts.logger.info("SSE connection closed");
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Internal
106
+ // ---------------------------------------------------------------------------
107
+
108
+ private async consumeStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
109
+ const decoder = new TextDecoder();
110
+ const reader = body.getReader();
111
+ let buffer = "";
112
+
113
+ try {
114
+ while (true) {
115
+ const { done, value } = await reader.read();
116
+ if (done || signal.aborted) break;
117
+
118
+ buffer += decoder.decode(value, { stream: true });
119
+
120
+ // SSE messages are delimited by double newlines
121
+ let boundary: number;
122
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
123
+ const raw = buffer.slice(0, boundary);
124
+ buffer = buffer.slice(boundary + 2);
125
+ this.processMessage(raw);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ if (signal.aborted) return; // intentional disconnect
130
+ this.opts.logger.warn(`SSE stream error: ${err}`);
131
+ } finally {
132
+ try {
133
+ reader.releaseLock();
134
+ } catch {
135
+ // already released
136
+ }
137
+ }
138
+
139
+ // Stream ended unexpectedly — reconnect unless we were intentionally disconnected
140
+ if (!signal.aborted) {
141
+ this.opts.logger.warn("SSE stream ended, scheduling reconnect");
142
+ this.scheduleReconnect();
143
+ }
144
+ }
145
+
146
+ private processMessage(raw: string): void {
147
+ for (const line of raw.split("\n")) {
148
+ // Comment lines (heartbeats) — ignore
149
+ if (line.startsWith(":")) continue;
150
+
151
+ // Data lines
152
+ if (line.startsWith("data: ")) {
153
+ const jsonStr = line.slice(6);
154
+ try {
155
+ const event: SseNotificationEvent = JSON.parse(jsonStr);
156
+ this.opts.onEvent(event);
157
+ } catch (err) {
158
+ this.opts.logger.warn(`SSE JSON parse error: ${err} — raw: ${jsonStr}`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ private scheduleReconnect(): void {
165
+ this.clearReconnectTimer();
166
+ this._status = "reconnecting";
167
+
168
+ this.opts.logger.info(`SSE reconnecting in ${this.reconnectDelay}ms`);
169
+ this.reconnectTimer = setTimeout(() => {
170
+ this.reconnectTimer = null;
171
+ this.connect();
172
+ }, this.reconnectDelay);
173
+
174
+ // Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s
175
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_DELAY_MS);
176
+ }
177
+
178
+ private clearReconnectTimer(): void {
179
+ if (this.reconnectTimer !== null) {
180
+ clearTimeout(this.reconnectTimer);
181
+ this.reconnectTimer = null;
182
+ }
183
+ }
184
+ }