@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.
- package/CHANGELOG.md +6 -0
- package/dist/index.js +219 -413
- package/e2e/api.spec.ts +132 -186
- package/e2e/sse_transport.spec.ts +69 -29
- package/e2e/sse_transport_multi.spec.ts +73 -137
- package/e2e/utils/FastMCPHandshakeTransport.ts +183 -0
- package/package.json +6 -9
- package/scripts/postbuild.js +24 -0
- package/src/index.ts +292 -541
- package/src/openmemory-js.d.ts +6 -0
- package/test-handshake.ts +26 -0
|
@@ -1,26 +1,18 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1
2
|
import { expect, test } from "@playwright/test";
|
|
2
|
-
import {
|
|
3
|
+
import { spawn } from "child_process";
|
|
3
4
|
import path from "path";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
111
|
-
for (
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
91
|
+
const result = await clients[i].callTool({
|
|
92
|
+
name: "add_memory",
|
|
93
|
+
arguments: { content, tags: ["multi", `client-${i}`] },
|
|
94
|
+
});
|
|
118
95
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
159
|
-
const
|
|
160
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|