@cybermem/mcp 0.14.15 → 0.15.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.
@@ -1,26 +1,18 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1
2
  import { expect, test } from "@playwright/test";
2
- import { ChildProcess, spawn } from "child_process";
3
+ import { spawn } from "child_process";
3
4
  import path from "path";
4
5
 
5
- /**
6
- * MCP SSE Transport Multi-Session Test
7
- *
8
- * This test validates:
9
- * 1. Multiple concurrent SSE connections
10
- * 2. Handling of missing X-Client-Name headers
11
- * 3. Rapid connection establishment and teardown behavior
12
- * 4. Graceful handling of malformed SSE requests and overall server health
13
- *
14
- * Purpose: Prevent SSE transport regressions identified in 0.12-0.14 releases
15
- */
16
- test.describe("MCP SSE Transport - Multi-Session", () => {
17
- let serverProcess: ChildProcess;
18
- const PORT = 3102; // Unique port to avoid conflicts
19
-
20
- test.setTimeout(120000);
6
+ import { FastMCPHandshakeTransport } from "./utils/FastMCPHandshakeTransport";
7
+
8
+ const PORT = 3103;
9
+ const BASE_URL = `http://localhost:${PORT}`;
10
+
11
+ test.describe("FastMCP SSE: Multi-Client Isolation", () => {
12
+ let serverProcess: any;
21
13
 
22
14
  test.beforeAll(async () => {
23
- // Start the server in http mode with in-memory DB
15
+ // Spawn server with :memory: DB for isolation
24
16
  const serverPath = path.join(__dirname, "../dist/index.js");
25
17
  serverProcess = spawn(
26
18
  "node",
@@ -39,146 +31,90 @@ test.describe("MCP SSE Transport - Multi-Session", () => {
39
31
  },
40
32
  );
41
33
 
42
- // Wait for server to start
43
34
  await new Promise<void>((resolve, reject) => {
44
- let output = "";
45
- const timeout = setTimeout(() => {
46
- reject(new Error(`Server start timeout. Output: ${output}`));
47
- }, 60000);
48
-
49
- serverProcess.stderr?.on("data", (data) => {
50
- const text = data.toString();
51
- output += text;
52
- console.log("[Server]", text);
53
- if (text.includes(`CyberMem MCP running on http://localhost:${PORT}`)) {
54
- clearTimeout(timeout);
35
+ serverProcess.stderr?.on("data", (data: any) => {
36
+ const output = data.toString();
37
+ if (
38
+ output.includes(`CyberMem MCP running on http://localhost:${PORT}`)
39
+ ) {
55
40
  resolve();
56
41
  }
57
42
  });
58
-
59
- serverProcess.on("error", (err) => {
60
- clearTimeout(timeout);
61
- reject(err);
62
- });
43
+ serverProcess.on("error", reject);
44
+ setTimeout(() => reject(new Error("Server start timeout")), 30000);
63
45
  });
64
46
  });
65
47
 
66
48
  test.afterAll(() => {
67
- if (serverProcess && !serverProcess.killed) {
49
+ if (serverProcess) {
68
50
  serverProcess.kill();
69
51
  }
52
+ // Final cleanup of port just in case
53
+ spawn("sh", ["-c", `lsof -ti:${PORT} | xargs kill -9 || true`]);
70
54
  });
71
55
 
72
- test("should handle multiple concurrent SSE connections", async () => {
73
- const connections: Array<{
74
- response: Response;
75
- reader: ReadableStreamDefaultReader<Uint8Array>;
76
- }> = [];
77
-
78
- // Open 3 concurrent SSE connections
79
- for (let i = 0; i < 3; i++) {
80
- const response = await fetch(`http://localhost:${PORT}/sse`, {
81
- headers: { "X-Client-Name": `test-client-${i}` },
82
- });
83
- expect(response.status).toBe(200);
84
- expect(response.headers.get("content-type")).toBe("text/event-stream");
85
-
86
- const reader = response.body!.getReader();
87
- connections.push({ response, reader });
88
- }
89
-
90
- // Verify all connections receive endpoint events
91
- const decoder = new TextDecoder();
92
- for (let i = 0; i < connections.length; i++) {
93
- let endpointFound = false;
94
- const { reader } = connections[i];
95
-
96
- for (let j = 0; j < 3; j++) {
97
- const { value, done } = await reader.read();
98
- if (done) break;
99
- const text = decoder.decode(value);
100
- console.log(`[Connection ${i}]`, text);
101
- if (text.includes("event: endpoint")) {
102
- endpointFound = true;
103
- break;
104
- }
105
- }
106
-
107
- expect(endpointFound).toBe(true);
56
+ test("Should support multiple clients with isolated sessions", async () => {
57
+ const clients: Client[] = [];
58
+ const transients: FastMCPHandshakeTransport[] = [];
59
+ const clientCount = 3;
60
+
61
+ console.log(`🚀 Starting Multi-Client Test (${clientCount} clients)`);
62
+
63
+ // 1. Establish sessions for all clients
64
+ for (let i = 0; i < clientCount; i++) {
65
+ const client = new Client(
66
+ { name: `multi-client-${i}`, version: "1.0.0" },
67
+ { capabilities: {} },
68
+ );
69
+ const transport = new FastMCPHandshakeTransport(
70
+ new URL(`${BASE_URL}/mcp`),
71
+ {
72
+ "X-Client-Name": `client-${i}`,
73
+ },
74
+ );
75
+
76
+ console.log(` [Client ${i}] Connecting...`);
77
+ await client.connect(transport);
78
+ console.log(
79
+ ` [Client ${i}] Connected. Session ID: ${transport.sessionId}`,
80
+ );
81
+
82
+ clients.push(client);
83
+ transients.push(transport);
108
84
  }
109
85
 
110
- // Cleanup all connections
111
- for (const { reader } of connections) {
112
- await reader.cancel();
113
- }
86
+ // 2. Perform tool calls and verify identity propagation
87
+ for (let i = 0; i < clientCount; i++) {
88
+ const content = `Memory from client ${i} at ${new Date().toISOString()}`;
89
+ console.log(` [Client ${i}] Calling add_memory...`);
114
90
 
115
- // Server should still be running
116
- expect(serverProcess.exitCode).toBeNull();
117
- });
91
+ const result = await clients[i].callTool({
92
+ name: "add_memory",
93
+ arguments: { content, tags: ["multi", `client-${i}`] },
94
+ });
118
95
 
119
- test("should handle connection with missing X-Client-Name header", async () => {
120
- // Connection should still work but may not have proper client identification
121
- const response = await fetch(`http://localhost:${PORT}/sse`);
122
- expect(response.status).toBe(200);
123
- expect(response.headers.get("content-type")).toBe("text/event-stream");
124
-
125
- const reader = response.body!.getReader();
126
- const decoder = new TextDecoder();
127
- let endpointFound = false;
128
-
129
- for (let i = 0; i < 3; i++) {
130
- const { value, done } = await reader.read();
131
- if (done) break;
132
- const text = decoder.decode(value);
133
- if (text.includes("event: endpoint")) {
134
- endpointFound = true;
135
- break;
136
- }
96
+ expect(result.isError).toBeFalsy();
97
+ console.log(` [Client ${i}] Tool call successful`);
137
98
  }
138
99
 
139
- expect(endpointFound).toBe(true);
140
- await reader.cancel();
141
- });
142
-
143
- test("should handle rapid connection establishment and teardown", async () => {
144
- // Simulate client reconnection scenarios
145
- for (let i = 0; i < 5; i++) {
146
- const response = await fetch(`http://localhost:${PORT}/sse`, {
147
- headers: { "X-Client-Name": `rapid-test-${i}` },
100
+ // 3. Verify all clients can still query (sessions persist)
101
+ for (let i = 0; i < clientCount; i++) {
102
+ console.log(` [Client ${i}] Querying memories...`);
103
+ const result = await clients[i].callTool({
104
+ name: "query_memory",
105
+ arguments: { query: `client-${i}`, k: 1 },
148
106
  });
149
- expect(response.status).toBe(200);
150
-
151
- const reader = response.body!.getReader();
152
107
 
153
- // Read one chunk then immediately disconnect
154
- await reader.read();
155
- await reader.cancel();
108
+ const body = JSON.parse((result.content as any[])[0].text);
109
+ expect(body.length).toBeGreaterThan(0);
110
+ console.log(
111
+ ` [Client ${i}] ✅ Query successful, found ${body.length} records`,
112
+ );
156
113
  }
157
114
 
158
- // Server should still be healthy
159
- const healthResponse = await fetch(`http://localhost:${PORT}/health`);
160
- expect(healthResponse.status).toBe(200);
161
- });
162
-
163
- test("should handle malformed SSE requests gracefully", async () => {
164
- // POST to /sse should not crash the server
165
- const postResponse = await fetch(`http://localhost:${PORT}/sse`, {
166
- method: "POST",
167
- });
168
- // POST may return 404/405/400 depending on SDK version — just verify it's not 200
169
- expect(postResponse.status).not.toBe(200);
170
-
171
- // GET with suspicious headers should still work
172
- const getResponse = await fetch(`http://localhost:${PORT}/sse`, {
173
- method: "GET",
174
- headers: { "X-Forwarded-For": "attacker.com" },
175
- });
176
- expect(getResponse.status).toBe(200);
177
- const reader = getResponse.body!.getReader();
178
- await reader.cancel();
179
-
180
- // Server should still be healthy after both requests
181
- const healthResponse = await fetch(`http://localhost:${PORT}/health`);
182
- expect(healthResponse.status).toBe(200);
115
+ // 4. Cleanup
116
+ for (const transport of transients) {
117
+ await transport.close();
118
+ }
183
119
  });
184
120
  });
@@ -0,0 +1,183 @@
1
+ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
+ import {
3
+ JSONRPCMessage,
4
+ JSONRPCMessageSchema,
5
+ } from "@modelcontextprotocol/sdk/types.js";
6
+
7
+ /**
8
+ * Shared transport for FastMCP httpStream mode.
9
+ * Uses manual fetch reader instead of EventSource for Node.js reliability.
10
+ *
11
+ * Extracted into a shared utility to avoid duplication across test files.
12
+ */
13
+ export class FastMCPHandshakeTransport implements Transport {
14
+ public sessionId: string | undefined = undefined;
15
+ private endpoint: URL;
16
+ private headers: Record<string, string>;
17
+ private abortController: AbortController | null = null;
18
+ private isClosing = false;
19
+ private streamReady: Promise<void> | null = null;
20
+
21
+ onclose?: () => void;
22
+ onerror?: (error: Error) => void;
23
+ onmessage?: (message: JSONRPCMessage) => void;
24
+
25
+ constructor(
26
+ endpoint: URL,
27
+ headers: Record<string, string> = {},
28
+ private clientName: string = "test-client",
29
+ ) {
30
+ this.endpoint = endpoint;
31
+ this.headers = headers;
32
+ }
33
+
34
+ async start(): Promise<void> {
35
+ // 1. Handshake (POST)
36
+ const initResponse = await fetch(this.endpoint.toString(), {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Accept: "application/json, text/event-stream",
41
+ ...this.headers,
42
+ },
43
+ body: JSON.stringify({
44
+ jsonrpc: "2.0",
45
+ id: "handshake",
46
+ method: "initialize",
47
+ params: {
48
+ protocolVersion: "2024-11-05",
49
+ capabilities: {},
50
+ clientInfo: { name: this.clientName, version: "1.0.0" },
51
+ },
52
+ }),
53
+ });
54
+
55
+ if (!initResponse.ok) {
56
+ throw new Error(`Handshake failed: ${initResponse.status}`);
57
+ }
58
+
59
+ this.sessionId = initResponse.headers.get("mcp-session-id") || undefined;
60
+ if (!this.sessionId) {
61
+ throw new Error("No mcp-session-id received");
62
+ }
63
+
64
+ // 2. Start Stream (GET)
65
+ this.abortController = new AbortController();
66
+ const streamResponse = await fetch(this.endpoint.toString(), {
67
+ headers: {
68
+ ...this.headers,
69
+ Accept: "text/event-stream",
70
+ "mcp-session-id": this.sessionId,
71
+ },
72
+ signal: this.abortController.signal,
73
+ });
74
+
75
+ if (!streamResponse.ok) {
76
+ throw new Error(`Stream establishment failed: ${streamResponse.status}`);
77
+ }
78
+
79
+ // Event-driven stream readiness: resolve when the first chunk is received
80
+ let resolveStreamReady: () => void;
81
+ this.streamReady = new Promise((r) => {
82
+ resolveStreamReady = r;
83
+ });
84
+
85
+ this.readStream(streamResponse.body!, () => resolveStreamReady()).catch(
86
+ (err) => {
87
+ if (!this.isClosing) {
88
+ console.error(" [Transport] Stream error:", err);
89
+ this.onerror?.(err);
90
+ }
91
+ },
92
+ );
93
+
94
+ // Wait for stream to be established (first chunk received or timeout)
95
+ await Promise.race([
96
+ this.streamReady,
97
+ new Promise((r) => setTimeout(r, 2000)),
98
+ ]);
99
+ }
100
+
101
+ private async readStream(
102
+ body: ReadableStream<Uint8Array>,
103
+ onFirstChunk?: () => void,
104
+ ) {
105
+ const reader = body.getReader();
106
+ const decoder = new TextDecoder();
107
+ let buffer = "";
108
+ let firstChunkReceived = false;
109
+
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done) break;
114
+
115
+ // Signal stream readiness on first data
116
+ if (!firstChunkReceived) {
117
+ firstChunkReceived = true;
118
+ onFirstChunk?.();
119
+ }
120
+
121
+ buffer += decoder.decode(value, { stream: true });
122
+ const lines = buffer.split("\n");
123
+ buffer = lines.pop() || "";
124
+
125
+ for (const line of lines) {
126
+ if (line.trim() === "") continue;
127
+ if (line.startsWith("data: ")) {
128
+ const data = line.slice(6).trim();
129
+ if (data) {
130
+ try {
131
+ const message = JSONRPCMessageSchema.parse(JSON.parse(data));
132
+ this.onmessage?.(message);
133
+ } catch (err) {
134
+ // Not a valid JSON-RPC message, skip
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ } catch (err) {
141
+ if (!this.isClosing) {
142
+ console.error(" [Transport] Reader error:", err);
143
+ }
144
+ } finally {
145
+ reader.releaseLock();
146
+ }
147
+ }
148
+
149
+ async send(message: JSONRPCMessage): Promise<void> {
150
+ if (!this.sessionId) throw new Error("Not connected");
151
+
152
+ const response = await fetch(this.endpoint.toString(), {
153
+ method: "POST",
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ Accept: "application/json, text/event-stream",
157
+ "mcp-session-id": this.sessionId,
158
+ ...this.headers,
159
+ },
160
+ body: JSON.stringify(message),
161
+ });
162
+
163
+ if (!response.ok) {
164
+ throw new Error(`Failed to send message: ${response.status}`);
165
+ }
166
+
167
+ // If result is in the response (enableJsonResponse: true), emit it
168
+ if (response.headers.get("Content-Type")?.includes("application/json")) {
169
+ try {
170
+ const body = await response.json();
171
+ this.onmessage?.(body);
172
+ } catch (err) {
173
+ // Not JSON or empty (e.g. 202 accepted) - ignore
174
+ }
175
+ }
176
+ }
177
+
178
+ async close(): Promise<void> {
179
+ this.isClosing = true;
180
+ this.abortController?.abort();
181
+ this.onclose?.();
182
+ }
183
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@cybermem/mcp",
3
- "version": "0.14.15",
3
+ "version": "0.15.0",
4
4
  "description": "CyberMem MCP Server - AI Memory with openmemory-js SDK",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "cybermem-mcp": "./dist/index.js"
8
8
  },
9
9
  "scripts": {
10
- "build": "tsc",
10
+ "build": "tsc && npm run postbuild",
11
+ "postbuild": "node scripts/postbuild.js",
11
12
  "start": "node dist/index.js",
12
13
  "dev": "ts-node src/index.ts",
13
14
  "lint": "tsc --noEmit",
@@ -38,22 +39,18 @@
38
39
  "access": "public"
39
40
  },
40
41
  "dependencies": {
41
- "@modelcontextprotocol/sdk": "^1.0.0",
42
- "cors": "^2.8.5",
43
42
  "dotenv": "^16.0.0",
44
- "express": "^5.2.1",
45
- "open": "^11.0.0",
43
+ "fastmcp": "^3.33.0",
46
44
  "openmemory-js": "1.3.0",
47
45
  "sqlite": "^5.1.1",
48
46
  "sqlite3": "^5.1.7",
49
47
  "zod": "^3.25.76"
50
48
  },
51
49
  "devDependencies": {
50
+ "@modelcontextprotocol/inspector": "^0.19.0",
52
51
  "@playwright/test": "^1.57.0",
53
- "@types/cors": "^2.8.19",
54
- "@types/express": "^5.0.6",
55
52
  "@types/node": "^18.0.0",
56
- "@modelcontextprotocol/inspector": "^0.19.0",
53
+ "eventsource": "^4.1.0",
57
54
  "ts-node": "^10.9.1",
58
55
  "typescript": "^5.0.0"
59
56
  }
@@ -0,0 +1,24 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const distFile = path.join(__dirname, "..", "dist", "index.js");
5
+ if (!fs.existsSync(distFile)) {
6
+ console.error("dist/index.js not found");
7
+ process.exit(1);
8
+ }
9
+
10
+ let content = fs.readFileSync(distFile, "utf8");
11
+ const shebang = "#!/usr/bin/env node\n";
12
+
13
+ if (!content.startsWith(shebang)) {
14
+ console.log("Adding shebang to dist/index.js");
15
+ content = shebang + content;
16
+ fs.writeFileSync(distFile, content);
17
+ }
18
+
19
+ try {
20
+ fs.chmodSync(distFile, 0o755);
21
+ console.log("Set executable permissions for dist/index.js");
22
+ } catch (err) {
23
+ console.warn("Could not set executable permissions:", err.message);
24
+ }