@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,360 @@
1
+ import type { SynapseMcpClient } from "./mcp-client.js";
2
+ import type { SynapsePluginConfig } from "./config.js";
3
+ import type { SseNotificationEvent } from "./sse-listener.js";
4
+
5
+ export interface SynapseEventRouterOptions {
6
+ mcpClient: SynapseMcpClient;
7
+ config: SynapsePluginConfig;
8
+ triggerAgent: (message: string, metadata?: Record<string, unknown>) => void;
9
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
10
+ }
11
+
12
+ /**
13
+ * Notification detail returned from synapse_get_notifications.
14
+ * Only the fields we need for routing.
15
+ */
16
+ interface NotificationDetail {
17
+ uuid: string;
18
+ projectUuid?: string;
19
+ researchProjectUuid?: string;
20
+ entityType: string;
21
+ entityUuid: string;
22
+ entityTitle: string;
23
+ action: string;
24
+ message: string;
25
+ actorType: string;
26
+ actorUuid: string;
27
+ actorName: string;
28
+ }
29
+
30
+ interface ExperimentDetail {
31
+ uuid: string;
32
+ researchProjectUuid: string;
33
+ title: string;
34
+ description: string | null;
35
+ priority: string;
36
+ computeBudgetHours: number | null;
37
+ attachments: Array<{ originalName: string }> | null;
38
+ researchQuestion?: { uuid: string; title: string } | null;
39
+ parentQuestionExperiments?: Array<{ title: string; status: string; outcome: string | null }> | null;
40
+ }
41
+
42
+ interface ResearchProjectDetail {
43
+ uuid: string;
44
+ name: string;
45
+ description: string | null;
46
+ goal: string | null;
47
+ datasets: Array<unknown> | null;
48
+ evaluationMethods: Array<unknown> | null;
49
+ }
50
+
51
+ export class SynapseEventRouter {
52
+ private readonly mcpClient: SynapseMcpClient;
53
+ private readonly config: SynapsePluginConfig;
54
+ private readonly triggerAgent: SynapseEventRouterOptions["triggerAgent"];
55
+ private readonly logger: SynapseEventRouterOptions["logger"];
56
+ private readonly projectFilter: Set<string>;
57
+
58
+ constructor(opts: SynapseEventRouterOptions) {
59
+ this.mcpClient = opts.mcpClient;
60
+ this.config = opts.config;
61
+ this.triggerAgent = opts.triggerAgent;
62
+ this.logger = opts.logger;
63
+ this.projectFilter = new Set(opts.config.projectUuids ?? []);
64
+ }
65
+
66
+ /**
67
+ * Route an incoming SSE notification event to the appropriate handler.
68
+ * Never throws — all errors are caught and logged internally.
69
+ */
70
+ dispatch(event: SseNotificationEvent): void {
71
+ // Only handle new_notification events (ignore count_update, etc.)
72
+ if (event.type !== "new_notification") {
73
+ this.logger.info(`SSE event type "${event.type}" ignored`);
74
+ return;
75
+ }
76
+
77
+ if (!event.notificationUuid) {
78
+ this.logger.warn("new_notification event missing notificationUuid, skipping");
79
+ return;
80
+ }
81
+
82
+ // Fetch full notification details and route asynchronously
83
+ this.fetchAndRoute(event.notificationUuid).catch((err) => {
84
+ this.logger.error(`Failed to fetch/route notification ${event.notificationUuid}: ${err}`);
85
+ });
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Internal
90
+ // ---------------------------------------------------------------------------
91
+
92
+ private async fetchAndRoute(notificationUuid: string): Promise<void> {
93
+ // Fetch notification details via MCP — use autoMarkRead=false so we don't
94
+ // consume all unread notifications, and status=unread since we just received it
95
+ const result = await this.mcpClient.callTool("synapse_get_notifications", {
96
+ status: "unread",
97
+ limit: 50,
98
+ autoMarkRead: false,
99
+ }) as { notifications?: NotificationDetail[] } | null;
100
+
101
+ const notifications = result?.notifications;
102
+ if (!notifications || !Array.isArray(notifications)) {
103
+ this.logger.warn(`Could not fetch notifications list`);
104
+ return;
105
+ }
106
+
107
+ const notification = notifications.find((n) => n.uuid === notificationUuid);
108
+ if (!notification) {
109
+ this.logger.warn(`Notification ${notificationUuid} not found in unread list`);
110
+ return;
111
+ }
112
+
113
+ // Project filter: if projectUuids is configured, ignore events from other projects
114
+ const projectUuid = notification.projectUuid ?? notification.researchProjectUuid ?? "";
115
+
116
+ if (this.projectFilter.size > 0 && !this.projectFilter.has(projectUuid)) {
117
+ this.logger.info(
118
+ `Notification for project ${projectUuid} filtered out`
119
+ );
120
+ return;
121
+ }
122
+
123
+ // Route based on action (which corresponds to notificationType)
124
+ try {
125
+ switch (notification.action) {
126
+ case "task_assigned":
127
+ case "run_assigned":
128
+ await this.handleExperimentAssigned(notification);
129
+ break;
130
+ case "mentioned":
131
+ this.handleMentioned(notification);
132
+ break;
133
+ case "elaboration_requested":
134
+ case "hypothesis_formulation_requested":
135
+ this.handleHypothesisFormulationRequested(notification);
136
+ break;
137
+ case "elaboration_answered":
138
+ case "hypothesis_formulation_answered":
139
+ this.handleHypothesisFormulationAnswered(notification);
140
+ break;
141
+ case "research_question_claimed":
142
+ case "idea_claimed":
143
+ this.handleResearchQuestionClaimed(notification);
144
+ break;
145
+ case "autonomous_loop_triggered":
146
+ this.handleAutonomousLoopTriggered(notification);
147
+ break;
148
+ case "deep_research_requested":
149
+ this.handleDeepResearchRequested(notification);
150
+ break;
151
+ case "experiment_report_requested":
152
+ this.handleExperimentReportRequested(notification);
153
+ break;
154
+ default:
155
+ this.logger.info(`Unhandled notification action: "${notification.action}"`);
156
+ break;
157
+ }
158
+ } catch (err) {
159
+ this.logger.error(`Error handling ${notification.action} notification: ${err}`);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Build @mention guidance for agent messages.
165
+ * Instructs the agent to @mention the actor after completing work.
166
+ */
167
+ private buildMentionGuidance(n: NotificationDetail, entityType: string): string {
168
+ return (
169
+ `After completing your work, post a comment on this ${entityType} using synapse_add_comment with @mention:\n` +
170
+ `Use this exact mention format: @[${n.actorName}](${n.actorType}:${n.actorUuid})`
171
+ );
172
+ }
173
+
174
+ private formatList(values: Array<unknown> | null | undefined): string {
175
+ if (!values || values.length === 0) {
176
+ return "Not specified";
177
+ }
178
+
179
+ return values
180
+ .map((value) => {
181
+ if (typeof value === "string") {
182
+ return value;
183
+ }
184
+ return JSON.stringify(value);
185
+ })
186
+ .join("; ");
187
+ }
188
+
189
+ private async handleExperimentAssigned(n: NotificationDetail): Promise<void> {
190
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
191
+ const mentionGuidance = this.buildMentionGuidance(n, "experiment");
192
+
193
+ let experiment: ExperimentDetail | null = null;
194
+ let project: ResearchProjectDetail | null = null;
195
+
196
+ try {
197
+ const result = await this.mcpClient.callTool("synapse_get_experiment", {
198
+ experimentUuid: n.entityUuid,
199
+ }) as { experiment?: ExperimentDetail } | null;
200
+ experiment = result?.experiment ?? null;
201
+ } catch (err) {
202
+ this.logger.warn(`Failed to fetch experiment detail for wake prompt: ${err}`);
203
+ }
204
+
205
+ try {
206
+ const targetProjectUuid = experiment?.researchProjectUuid ?? projectUuid;
207
+ if (targetProjectUuid) {
208
+ const result = await this.mcpClient.callTool("synapse_get_research_project", {
209
+ researchProjectUuid: targetProjectUuid,
210
+ }) as ResearchProjectDetail | null;
211
+ project = result;
212
+ }
213
+ } catch (err) {
214
+ this.logger.warn(`Failed to fetch research project detail for wake prompt: ${err}`);
215
+ }
216
+
217
+ const contextLines = [
218
+ project?.name ? `Research project: ${project.name}` : null,
219
+ project?.goal ? `Goal: ${project.goal}` : null,
220
+ project?.description ? `Project brief: ${project.description}` : null,
221
+ project ? `Datasets: ${this.formatList(project.datasets)}` : null,
222
+ project ? `Evaluation methods: ${this.formatList(project.evaluationMethods)}` : null,
223
+ experiment?.description ? `Experiment description: ${experiment.description}` : null,
224
+ experiment?.researchQuestion?.title ? `Linked research question: ${experiment.researchQuestion.title}` : null,
225
+ experiment?.computeBudgetHours != null ? `Compute budget (hours): ${experiment.computeBudgetHours}` : "Compute budget (hours): Unlimited",
226
+ experiment?.attachments?.length
227
+ ? `Attached files: ${experiment.attachments.map((item) => item.originalName).join(", ")}`
228
+ : null,
229
+ experiment?.parentQuestionExperiments?.length
230
+ ? `Parent-question experiment context: ${experiment.parentQuestionExperiments
231
+ .map((item) => `${item.title} [${item.status}]${item.outcome ? ` outcome: ${item.outcome}` : ""}`)
232
+ .join("; ")}`
233
+ : null,
234
+ "If a selected compute node exposes managedKeyAvailable=true, call synapse_get_node_access_bundle with the experimentUuid and nodeUuid. Write the returned privateKeyPemBase64 to a local PEM file with chmod 600 before using ssh.",
235
+ ].filter(Boolean);
236
+
237
+ const prompt = [
238
+ `[Synapse] Experiment assigned: ${n.entityTitle}. Experiment UUID: ${n.entityUuid}, Project UUID: ${projectUuid}.`,
239
+ ...contextLines,
240
+ "Use synapse_get_assigned_experiments to inspect your current queue. Execute the highest-priority item first; experiments with priority 'immediate' must jump to the front of the queue, and experiments with the same priority should be handled FIFO.",
241
+ `Then use synapse_get_experiment with experimentUuid "${n.entityUuid}" to inspect full details, use synapse_list_compute_nodes to inspect available machines and GPUs, call synapse_start_experiment when you begin execution.`,
242
+ `During execution, call synapse_report_experiment_progress with experimentUuid "${n.entityUuid}" at each major step (e.g. data download, training start, evaluation) to update the live status on the experiment card.`,
243
+ `When finished, call synapse_submit_experiment_results with experimentUuid "${n.entityUuid}" to complete the experiment.`,
244
+ mentionGuidance,
245
+ ].join("\n");
246
+
247
+ // Compute timeout: use experiment's computeBudgetHours, or 24h if unlimited
248
+ const budgetHours = experiment?.computeBudgetHours;
249
+ const timeoutSeconds = budgetHours != null ? Math.ceil(budgetHours * 3600) : 24 * 3600;
250
+
251
+ this.triggerAgent(prompt, {
252
+ notificationUuid: n.uuid,
253
+ action: "task_assigned",
254
+ entityType: n.entityType,
255
+ entityUuid: n.entityUuid,
256
+ projectUuid,
257
+ timeoutSeconds,
258
+ });
259
+ }
260
+
261
+ private handleMentioned(n: NotificationDetail): void {
262
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
263
+ const mentionGuidance = this.buildMentionGuidance(n, n.entityType);
264
+
265
+ this.triggerAgent(
266
+ `[Synapse] You were @mentioned in ${n.entityType} '${n.entityTitle}' (entityType: ${n.entityType}, entityUuid: ${n.entityUuid}, projectUuid: ${projectUuid}): ${n.message}\n` +
267
+ `Review the ${n.entityType} content and use synapse_get_comments (targetType: "${n.entityType}", targetUuid: "${n.entityUuid}") to see the full conversation, then respond.\n` +
268
+ mentionGuidance,
269
+ { notificationUuid: n.uuid, action: "mentioned", entityUuid: n.entityUuid, projectUuid }
270
+ );
271
+ }
272
+
273
+ private handleHypothesisFormulationRequested(n: NotificationDetail): void {
274
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
275
+ this.triggerAgent(
276
+ `[Synapse] Hypothesis formulation requested for research question '${n.entityTitle}' (questionUuid: ${n.entityUuid}, projectUuid: ${projectUuid}). Use synapse_get_hypothesis_formulation to review questions.`,
277
+ { notificationUuid: n.uuid, action: "hypothesis_formulation_requested", entityUuid: n.entityUuid, projectUuid }
278
+ );
279
+ }
280
+
281
+ private handleHypothesisFormulationAnswered(n: NotificationDetail): void {
282
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
283
+ const mentionGuidance = this.buildMentionGuidance(n, "research question");
284
+
285
+ this.triggerAgent(
286
+ `[Synapse] Hypothesis formulation answers submitted for research question '${n.entityTitle}' (questionUuid: ${n.entityUuid}, projectUuid: ${projectUuid}). ` +
287
+ `Review the answers with synapse_get_hypothesis_formulation, then either resolve the round or start a follow-up round.\n\n` +
288
+ `After reviewing, @mention the answerer to ask if they have any further questions before you proceed.\n` +
289
+ mentionGuidance,
290
+ { notificationUuid: n.uuid, action: "hypothesis_formulation_answered", entityUuid: n.entityUuid, projectUuid }
291
+ );
292
+ }
293
+
294
+ private handleResearchQuestionClaimed(n: NotificationDetail): void {
295
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
296
+ const mentionGuidance = this.buildMentionGuidance(n, "research question");
297
+
298
+ this.triggerAgent(
299
+ `[Synapse] Research question '${n.entityTitle}' has been assigned to you (questionUuid: ${n.entityUuid}, projectUuid: ${projectUuid}). ` +
300
+ `Use synapse_get_research_question to review the question context.\n` +
301
+ mentionGuidance,
302
+ { notificationUuid: n.uuid, action: "research_question_claimed", entityUuid: n.entityUuid, projectUuid }
303
+ );
304
+ }
305
+
306
+ private handleAutonomousLoopTriggered(n: NotificationDetail): void {
307
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
308
+
309
+ this.triggerAgent(
310
+ `[Synapse] Autonomous research loop triggered for project "${n.entityTitle}" (projectUuid: ${projectUuid}).
311
+
312
+ The experiment queue is empty. Your task:
313
+ 1. Use synapse_get_project_full_context with researchProjectUuid "${projectUuid}" to review all project details, research questions, and experiment results
314
+ 2. Analyze: What questions remain unanswered? What experiments could yield new insights? Are there gaps in the research?
315
+ 3. If you identify valuable next steps, use synapse_propose_experiment to create draft experiments for human review
316
+ 4. If the research objectives appear to be met, you may choose not to propose any new experiments
317
+
318
+ Proposed experiments will enter "draft" status and require human approval before execution.`,
319
+ { notificationUuid: n.uuid, action: "autonomous_loop_triggered", entityUuid: n.entityUuid, projectUuid }
320
+ );
321
+ }
322
+
323
+ private handleDeepResearchRequested(n: NotificationDetail): void {
324
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
325
+
326
+ this.triggerAgent(
327
+ `[Synapse] Deep research literature review requested for project (projectUuid: ${projectUuid}).
328
+
329
+ 1. Use synapse_get_related_works with researchProjectUuid "${projectUuid}" to read all collected papers
330
+ 2. Use synapse_get_project_full_context with researchProjectUuid "${projectUuid}" to understand the research objectives
331
+ 3. Analyze how each paper relates to the project's goals — identify key methods, findings, and gaps in the literature
332
+ 4. Create a comprehensive literature review document summarizing your analysis`,
333
+ { notificationUuid: n.uuid, action: "deep_research_requested", entityUuid: n.entityUuid, projectUuid }
334
+ );
335
+ }
336
+
337
+ private handleExperimentReportRequested(n: NotificationDetail): void {
338
+ const projectUuid = n.projectUuid ?? n.researchProjectUuid ?? "";
339
+
340
+ this.triggerAgent(
341
+ `[Synapse] You just completed experiment "${n.entityTitle}" (experimentUuid: ${n.entityUuid}, projectUuid: ${projectUuid}).
342
+
343
+ Write a detailed experiment report document for this experiment. Follow these steps:
344
+
345
+ 1. Use synapse_get_experiment with experimentUuid "${n.entityUuid}" to read the full experiment details (description, outcome, results, compute usage)
346
+ 2. Use synapse_get_project_full_context with researchProjectUuid "${projectUuid}" to understand the broader research context
347
+ 3. Write a comprehensive experiment report that includes:
348
+ - Experiment objective and setup
349
+ - Methodology and approach
350
+ - Results and key findings
351
+ - Analysis and interpretation
352
+ - Conclusions and next steps
353
+ 4. Write the report in the same language as the project description.
354
+ 5. Use synapse_add_comment to post the report as a comment on the experiment (targetType: "experiment", targetUuid: "${n.entityUuid}")
355
+
356
+ Keep the report focused on THIS experiment only — do not summarize the entire project.`,
357
+ { notificationUuid: n.uuid, action: "experiment_report_requested", entityUuid: n.entityUuid, projectUuid }
358
+ );
359
+ }
360
+ }
package/src/index.ts ADDED
@@ -0,0 +1,152 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ type OpenClawPluginApi = any;
3
+
4
+ import { synapseConfigSchema, type SynapsePluginConfig, validateConfigWithWarnings } from "./config.js";
5
+ import { SynapseMcpClient } from "./mcp-client.js";
6
+ import { SynapseSseListener } from "./sse-listener.js";
7
+ import { SynapseEventRouter } from "./event-router.js";
8
+ import { registerCommonTools } from "./tools/common-tools.js";
9
+ import { registerSynapseCommands } from "./commands.js";
10
+
11
+ /**
12
+ * Trigger the OpenClaw agent by dispatching an isolated agent turn through
13
+ * the gateway's /hooks/agent endpoint. This treats the Synapse assignment as
14
+ * a primary prompt instead of a side-channel wake event.
15
+ */
16
+ const DEFAULT_TIMEOUT_SECONDS = 24 * 3600; // 24 hours for unlimited budget
17
+
18
+ async function wakeAgent(
19
+ gatewayUrl: string,
20
+ hooksToken: string,
21
+ text: string,
22
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
23
+ timeoutSeconds?: number,
24
+ ) {
25
+ try {
26
+ const res = await fetch(`${gatewayUrl}/hooks/agent`, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ Authorization: `Bearer ${hooksToken}`,
31
+ },
32
+ body: JSON.stringify({
33
+ message: text,
34
+ name: "Synapse",
35
+ wakeMode: "now",
36
+ deliver: false,
37
+ timeoutSeconds: timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
38
+ }),
39
+ });
40
+ if (!res.ok) {
41
+ logger.warn(`Wake agent failed: HTTP ${res.status}`);
42
+ } else {
43
+ logger.info(`Agent woken: ${text.slice(0, 80)}...`);
44
+ }
45
+ } catch (err) {
46
+ logger.warn(`Wake agent error: ${err}`);
47
+ }
48
+ }
49
+
50
+ const plugin = {
51
+ id: "synapse-openclaw-plugin",
52
+ name: "Synapse",
53
+ description:
54
+ "Synapse research orchestration platform — SSE real-time events + MCP tool integration",
55
+ configSchema: synapseConfigSchema,
56
+
57
+ register(api: OpenClawPluginApi) {
58
+ const rawConfig = api.pluginConfig ?? {};
59
+ const config: SynapsePluginConfig = {
60
+ synapseUrl: rawConfig.synapseUrl || undefined,
61
+ apiKey: rawConfig.apiKey || undefined,
62
+ projectUuids: rawConfig.projectUuids ?? [],
63
+ autoStart: rawConfig.autoStart ?? true,
64
+ };
65
+ const logger = api.logger;
66
+
67
+ if (!validateConfigWithWarnings(config, logger)) {
68
+ return;
69
+ }
70
+
71
+ // After validateConfigWithWarnings, synapseUrl and apiKey are guaranteed present
72
+ const synapseUrl = config.synapseUrl!;
73
+ const apiKey = config.apiKey!;
74
+
75
+ // Resolve gateway URL and hooks token from OpenClaw config
76
+ const gatewayPort = api.config?.gateway?.port ?? 18789;
77
+ const gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
78
+ const hooksToken = api.config?.hooks?.token ?? "";
79
+
80
+ logger.info(
81
+ `Synapse plugin initializing — ${synapseUrl} (${config.projectUuids?.length || "all"} projects)`
82
+ );
83
+
84
+ // --- MCP Client ---
85
+ const mcpClient = new SynapseMcpClient({
86
+ synapseUrl,
87
+ apiKey,
88
+ logger,
89
+ });
90
+
91
+ // --- Event Router ---
92
+ const eventRouter = new SynapseEventRouter({
93
+ mcpClient,
94
+ config,
95
+ logger,
96
+ triggerAgent: (message: string, metadata?: Record<string, unknown>) => {
97
+ const timeoutSeconds = metadata?.timeoutSeconds as number | undefined;
98
+ // Use /hooks/agent to create an isolated agent turn for Synapse work.
99
+ if (hooksToken) {
100
+ wakeAgent(gatewayUrl, hooksToken, message, logger, timeoutSeconds);
101
+ } else {
102
+ logger.warn(
103
+ `[Synapse] Cannot wake agent — hooks.token not configured. Event: ${message.slice(0, 100)}`
104
+ );
105
+ }
106
+ },
107
+ });
108
+
109
+ // --- SSE Listener (background service) ---
110
+ let sseListener: SynapseSseListener | null = null;
111
+
112
+ api.registerService({
113
+ id: "synapse-sse",
114
+ async start() {
115
+ sseListener = new SynapseSseListener({
116
+ synapseUrl,
117
+ apiKey,
118
+ logger,
119
+ onEvent: (event) => eventRouter.dispatch(event),
120
+ onReconnect: async () => {
121
+ // Back-fill missed notifications after reconnect
122
+ try {
123
+ const result = (await mcpClient.callTool("synapse_get_notifications", {
124
+ status: "unread",
125
+ autoMarkRead: false,
126
+ })) as { notifications?: Array<{ uuid: string }> } | null;
127
+ const count = result?.notifications?.length ?? 0;
128
+ if (count > 0) {
129
+ logger.info(`SSE reconnect: ${count} unread notifications to process`);
130
+ }
131
+ } catch (err) {
132
+ logger.warn(`Failed to back-fill notifications: ${err}`);
133
+ }
134
+ },
135
+ });
136
+ await sseListener.connect();
137
+ },
138
+ async stop() {
139
+ sseListener?.disconnect();
140
+ await mcpClient.disconnect();
141
+ },
142
+ });
143
+
144
+ // --- Tools (all tools available to all agents) ---
145
+ registerCommonTools(api, mcpClient);
146
+
147
+ // --- Commands ---
148
+ registerSynapseCommands(api, mcpClient, () => sseListener?.status ?? "disconnected");
149
+ },
150
+ };
151
+
152
+ export default plugin;
@@ -0,0 +1,130 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockClientConnect = vi.fn();
4
+ const mockClientCallTool = vi.fn();
5
+ const mockClientClose = vi.fn();
6
+ const mockClientConstructor = vi.fn();
7
+ const mockTransportConstructor = vi.fn();
8
+
9
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
10
+ Client: class MockClient {
11
+ constructor(config: unknown) {
12
+ mockClientConstructor(config);
13
+ }
14
+
15
+ connect = mockClientConnect;
16
+ callTool = mockClientCallTool;
17
+ close = mockClientClose;
18
+ },
19
+ }));
20
+
21
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
22
+ StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport {
23
+ constructor(url: URL, options: unknown) {
24
+ mockTransportConstructor(url, options);
25
+ }
26
+ },
27
+ }));
28
+
29
+ import { SynapseMcpClient } from "./mcp-client.js";
30
+
31
+ function createLogger() {
32
+ return {
33
+ info: vi.fn(),
34
+ warn: vi.fn(),
35
+ error: vi.fn(),
36
+ };
37
+ }
38
+
39
+ describe("SynapseMcpClient", () => {
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ mockClientConnect.mockResolvedValue(undefined);
43
+ mockClientClose.mockResolvedValue(undefined);
44
+ });
45
+
46
+ it("lazily connects and parses JSON tool output", async () => {
47
+ mockClientCallTool.mockResolvedValueOnce({
48
+ isError: false,
49
+ content: [{ type: "text", text: "{\"ok\":true}" }],
50
+ });
51
+
52
+ const logger = createLogger();
53
+ const client = new SynapseMcpClient({
54
+ synapseUrl: "https://synapse.example.com",
55
+ apiKey: "syn_test_key",
56
+ logger,
57
+ });
58
+
59
+ const result = await client.callTool("synapse_ping", { foo: "bar" });
60
+
61
+ expect(result).toEqual({ ok: true });
62
+ expect(mockTransportConstructor).toHaveBeenCalledWith(
63
+ new URL("https://synapse.example.com/api/mcp"),
64
+ expect.objectContaining({
65
+ requestInit: {
66
+ headers: {
67
+ Authorization: "Bearer syn_test_key",
68
+ },
69
+ },
70
+ }),
71
+ );
72
+ expect(mockClientConstructor).toHaveBeenCalledWith({
73
+ name: "openclaw-synapse",
74
+ version: "0.1.0",
75
+ });
76
+ expect(mockClientConnect).toHaveBeenCalledTimes(1);
77
+ expect(mockClientCallTool).toHaveBeenCalledWith({
78
+ name: "synapse_ping",
79
+ arguments: { foo: "bar" },
80
+ });
81
+ expect(client.status).toBe("connected");
82
+ expect(logger.info).toHaveBeenCalledWith("MCP connection established");
83
+ });
84
+
85
+ it("reconnects once when the MCP session expires", async () => {
86
+ mockClientCallTool
87
+ .mockRejectedValueOnce(new Error("404 session not found"))
88
+ .mockResolvedValueOnce({
89
+ isError: false,
90
+ content: [{ type: "text", text: "{\"retried\":true}" }],
91
+ });
92
+
93
+ const logger = createLogger();
94
+ const client = new SynapseMcpClient({
95
+ synapseUrl: "https://synapse.example.com",
96
+ apiKey: "syn_test_key",
97
+ logger,
98
+ });
99
+
100
+ const result = await client.callTool("synapse_retry_me");
101
+
102
+ expect(result).toEqual({ retried: true });
103
+ expect(mockClientConnect).toHaveBeenCalledTimes(2);
104
+ expect(mockClientClose).toHaveBeenCalledTimes(1);
105
+ expect(logger.warn).toHaveBeenCalledWith("MCP session expired, reconnecting...");
106
+ expect(client.status).toBe("connected");
107
+ });
108
+
109
+ it("returns raw text when the tool output is not JSON and disconnects cleanly", async () => {
110
+ mockClientCallTool.mockResolvedValueOnce({
111
+ isError: false,
112
+ content: [{ type: "text", text: "plain text result" }],
113
+ });
114
+
115
+ const logger = createLogger();
116
+ const client = new SynapseMcpClient({
117
+ synapseUrl: "https://synapse.example.com",
118
+ apiKey: "syn_test_key",
119
+ logger,
120
+ });
121
+
122
+ const result = await client.callTool("synapse_plain_text");
123
+ await client.disconnect();
124
+
125
+ expect(result).toBe("plain text result");
126
+ expect(mockClientClose).toHaveBeenCalledTimes(1);
127
+ expect(client.status).toBe("disconnected");
128
+ expect(logger.info).toHaveBeenLastCalledWith("MCP connection closed");
129
+ });
130
+ });