@mcp-web/bridge 0.1.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/LICENSE +201 -0
- package/README.md +311 -0
- package/dist/adapters/bun.d.ts +95 -0
- package/dist/adapters/bun.d.ts.map +1 -0
- package/dist/adapters/bun.js +286 -0
- package/dist/adapters/bun.js.map +1 -0
- package/dist/adapters/deno.d.ts +89 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +249 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/index.d.ts +21 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/node.d.ts +112 -0
- package/dist/adapters/node.d.ts.map +1 -0
- package/dist/adapters/node.js +309 -0
- package/dist/adapters/node.js.map +1 -0
- package/dist/adapters/partykit.d.ts +153 -0
- package/dist/adapters/partykit.d.ts.map +1 -0
- package/dist/adapters/partykit.js +372 -0
- package/dist/adapters/partykit.js.map +1 -0
- package/dist/bridge.d.ts +38 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +1004 -0
- package/dist/bridge.js.map +1 -0
- package/dist/core.d.ts +75 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1508 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/scheduler.d.ts +69 -0
- package/dist/runtime/scheduler.d.ts.map +1 -0
- package/dist/runtime/scheduler.js +88 -0
- package/dist/runtime/scheduler.js.map +1 -0
- package/dist/runtime/types.d.ts +144 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +82 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/schemas.d.ts +6 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +6 -0
- package/dist/schemas.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +28 -0
- package/src/adapters/bun.ts +354 -0
- package/src/adapters/deno.ts +282 -0
- package/src/adapters/index.ts +28 -0
- package/src/adapters/node.ts +385 -0
- package/src/adapters/partykit.ts +482 -0
- package/src/bridge.test.ts +64 -0
- package/src/core.ts +2176 -0
- package/src/index.ts +90 -0
- package/src/limits.test.ts +436 -0
- package/src/remote-mcp.test.ts +770 -0
- package/src/runtime/index.ts +24 -0
- package/src/runtime/scheduler.ts +130 -0
- package/src/runtime/types.ts +229 -0
- package/src/schemas.ts +6 -0
- package/src/session-naming.test.ts +443 -0
- package/src/types.ts +180 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, describe } from 'bun:test';
|
|
2
|
+
import { MCPWebBridgeNode } from './adapters/node.js';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
|
|
5
|
+
// Helper to create a mock WebSocket client
|
|
6
|
+
function createMockClient(port: number, sessionId: string): Promise<WebSocket> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const ws = new WebSocket(`ws://localhost:${port}?session=${sessionId}`);
|
|
9
|
+
ws.on('open', () => resolve(ws));
|
|
10
|
+
ws.on('error', reject);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Helper to wait for a specific message type
|
|
15
|
+
function waitForMessage<T>(ws: WebSocket, type: string, timeout = 5000): Promise<T> {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeout);
|
|
18
|
+
|
|
19
|
+
const handler = (data: Buffer) => {
|
|
20
|
+
try {
|
|
21
|
+
const message = JSON.parse(data.toString());
|
|
22
|
+
if (message.type === type) {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
ws.removeListener('message', handler);
|
|
25
|
+
resolve(message);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore parse errors
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
ws.on('message', handler);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Helper to authenticate a client
|
|
37
|
+
async function authenticateClient(ws: WebSocket, authToken: string): Promise<void> {
|
|
38
|
+
ws.send(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
type: 'authenticate',
|
|
41
|
+
authToken,
|
|
42
|
+
origin: 'http://localhost:3000',
|
|
43
|
+
pageTitle: 'Test Page',
|
|
44
|
+
userAgent: 'Test Agent',
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await waitForMessage(ws, 'authenticated');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper to register a tool
|
|
53
|
+
function registerTool(ws: WebSocket, name: string, description: string): void {
|
|
54
|
+
ws.send(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
type: 'register-tool',
|
|
57
|
+
tool: {
|
|
58
|
+
name,
|
|
59
|
+
description,
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
input: { type: 'string' },
|
|
64
|
+
},
|
|
65
|
+
required: ['input'],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Helper to make MCP JSON-RPC request
|
|
73
|
+
async function mcpRequest(
|
|
74
|
+
port: number,
|
|
75
|
+
method: string,
|
|
76
|
+
params?: Record<string, unknown>,
|
|
77
|
+
headers?: Record<string, string>,
|
|
78
|
+
queryParams?: Record<string, string>
|
|
79
|
+
): Promise<{ status: number; body: unknown; headers: Headers }> {
|
|
80
|
+
const url = new URL(`http://localhost:${port}`);
|
|
81
|
+
if (queryParams) {
|
|
82
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
83
|
+
url.searchParams.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const response = await fetch(url.toString(), {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
...headers,
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
jsonrpc: '2.0',
|
|
95
|
+
id: 1,
|
|
96
|
+
method,
|
|
97
|
+
params,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const body = await response.json();
|
|
102
|
+
return { status: response.status, body, headers: response.headers };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('Remote MCP - Initialize', () => {
|
|
106
|
+
let bridge: MCPWebBridgeNode;
|
|
107
|
+
const port = 4601;
|
|
108
|
+
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
if (bridge) {
|
|
111
|
+
await bridge.close();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('initialize returns Mcp-Session-Id header', async () => {
|
|
116
|
+
bridge = new MCPWebBridgeNode({
|
|
117
|
+
name: 'Test Bridge',
|
|
118
|
+
description: 'Test Remote MCP',
|
|
119
|
+
port,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const { status, body, headers } = await mcpRequest(
|
|
123
|
+
port,
|
|
124
|
+
'initialize',
|
|
125
|
+
{ protocolVersion: '2024-11-05' },
|
|
126
|
+
{ Authorization: 'Bearer test-token' }
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(status).toBe(200);
|
|
130
|
+
expect(body).toMatchObject({
|
|
131
|
+
jsonrpc: '2.0',
|
|
132
|
+
id: 1,
|
|
133
|
+
result: {
|
|
134
|
+
protocolVersion: '2024-11-05',
|
|
135
|
+
capabilities: {
|
|
136
|
+
tools: { listChanged: true },
|
|
137
|
+
resources: {},
|
|
138
|
+
prompts: {},
|
|
139
|
+
},
|
|
140
|
+
serverInfo: {
|
|
141
|
+
name: 'Test Bridge',
|
|
142
|
+
description: 'Test Remote MCP',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Should have Mcp-Session-Id header
|
|
148
|
+
const sessionId = headers.get('mcp-session-id');
|
|
149
|
+
expect(sessionId).toBeTruthy();
|
|
150
|
+
expect(typeof sessionId).toBe('string');
|
|
151
|
+
expect(sessionId!.length).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('initialize requires authorization header', async () => {
|
|
155
|
+
bridge = new MCPWebBridgeNode({
|
|
156
|
+
name: 'Test Bridge',
|
|
157
|
+
description: 'Test',
|
|
158
|
+
port,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const { status, body } = await mcpRequest(port, 'initialize', {
|
|
162
|
+
protocolVersion: '2024-11-05',
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(status).toBe(200);
|
|
166
|
+
expect(body).toMatchObject({
|
|
167
|
+
jsonrpc: '2.0',
|
|
168
|
+
id: 1,
|
|
169
|
+
error: {
|
|
170
|
+
code: -32600,
|
|
171
|
+
message: 'MissingAuthentication',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('initialize accepts token via query param (for Remote MCP)', async () => {
|
|
177
|
+
bridge = new MCPWebBridgeNode({
|
|
178
|
+
name: 'Test Bridge',
|
|
179
|
+
description: 'Test Remote MCP with query param',
|
|
180
|
+
port,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const { status, body, headers } = await mcpRequest(
|
|
184
|
+
port,
|
|
185
|
+
'initialize',
|
|
186
|
+
{ protocolVersion: '2024-11-05' },
|
|
187
|
+
undefined, // no Authorization header
|
|
188
|
+
{ token: 'query-param-token' }
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(status).toBe(200);
|
|
192
|
+
expect(body).toMatchObject({
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
id: 1,
|
|
195
|
+
result: {
|
|
196
|
+
protocolVersion: '2024-11-05',
|
|
197
|
+
capabilities: {
|
|
198
|
+
tools: { listChanged: true },
|
|
199
|
+
resources: {},
|
|
200
|
+
prompts: {},
|
|
201
|
+
},
|
|
202
|
+
serverInfo: {
|
|
203
|
+
name: 'Test Bridge',
|
|
204
|
+
description: 'Test Remote MCP with query param',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Should have Mcp-Session-Id header
|
|
210
|
+
const sessionId = headers.get('mcp-session-id');
|
|
211
|
+
expect(sessionId).toBeTruthy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('initialize creates unique session IDs', async () => {
|
|
215
|
+
bridge = new MCPWebBridgeNode({
|
|
216
|
+
name: 'Test Bridge',
|
|
217
|
+
description: 'Test',
|
|
218
|
+
port,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const session1 = await mcpRequest(
|
|
222
|
+
port,
|
|
223
|
+
'initialize',
|
|
224
|
+
{ protocolVersion: '2024-11-05' },
|
|
225
|
+
{ Authorization: 'Bearer test-token' }
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const session2 = await mcpRequest(
|
|
229
|
+
port,
|
|
230
|
+
'initialize',
|
|
231
|
+
{ protocolVersion: '2024-11-05' },
|
|
232
|
+
{ Authorization: 'Bearer test-token' }
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const sessionId1 = session1.headers.get('mcp-session-id');
|
|
236
|
+
const sessionId2 = session2.headers.get('mcp-session-id');
|
|
237
|
+
|
|
238
|
+
expect(sessionId1).toBeTruthy();
|
|
239
|
+
expect(sessionId2).toBeTruthy();
|
|
240
|
+
expect(sessionId1).not.toBe(sessionId2);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('Remote MCP - Session Management', () => {
|
|
245
|
+
let bridge: MCPWebBridgeNode;
|
|
246
|
+
const port = 4602;
|
|
247
|
+
|
|
248
|
+
afterEach(async () => {
|
|
249
|
+
if (bridge) {
|
|
250
|
+
await bridge.close();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('subsequent requests with Mcp-Session-Id are accepted', async () => {
|
|
255
|
+
bridge = new MCPWebBridgeNode({
|
|
256
|
+
name: 'Test Bridge',
|
|
257
|
+
description: 'Test',
|
|
258
|
+
port,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Initialize to get session ID
|
|
262
|
+
const initResponse = await mcpRequest(
|
|
263
|
+
port,
|
|
264
|
+
'initialize',
|
|
265
|
+
{ protocolVersion: '2024-11-05' },
|
|
266
|
+
{ Authorization: 'Bearer test-token' }
|
|
267
|
+
);
|
|
268
|
+
const sessionId = initResponse.headers.get('mcp-session-id')!;
|
|
269
|
+
|
|
270
|
+
// Send notifications/initialized
|
|
271
|
+
const initializedResponse = await mcpRequest(
|
|
272
|
+
port,
|
|
273
|
+
'notifications/initialized',
|
|
274
|
+
{},
|
|
275
|
+
{
|
|
276
|
+
Authorization: 'Bearer test-token',
|
|
277
|
+
'Mcp-Session-Id': sessionId,
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(initializedResponse.status).toBe(202);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('DELETE with Mcp-Session-Id closes the session', async () => {
|
|
285
|
+
bridge = new MCPWebBridgeNode({
|
|
286
|
+
name: 'Test Bridge',
|
|
287
|
+
description: 'Test',
|
|
288
|
+
port,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Initialize to get session ID
|
|
292
|
+
const initResponse = await mcpRequest(
|
|
293
|
+
port,
|
|
294
|
+
'initialize',
|
|
295
|
+
{ protocolVersion: '2024-11-05' },
|
|
296
|
+
{ Authorization: 'Bearer test-token' }
|
|
297
|
+
);
|
|
298
|
+
const sessionId = initResponse.headers.get('mcp-session-id')!;
|
|
299
|
+
|
|
300
|
+
// Delete the session
|
|
301
|
+
const deleteResponse = await fetch(`http://localhost:${port}`, {
|
|
302
|
+
method: 'DELETE',
|
|
303
|
+
headers: {
|
|
304
|
+
'Mcp-Session-Id': sessionId,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(deleteResponse.status).toBe(200);
|
|
309
|
+
const body = await deleteResponse.json();
|
|
310
|
+
expect(body).toEqual({ success: true });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('DELETE with invalid session ID returns 404', async () => {
|
|
314
|
+
bridge = new MCPWebBridgeNode({
|
|
315
|
+
name: 'Test Bridge',
|
|
316
|
+
description: 'Test',
|
|
317
|
+
port,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const deleteResponse = await fetch(`http://localhost:${port}`, {
|
|
321
|
+
method: 'DELETE',
|
|
322
|
+
headers: {
|
|
323
|
+
'Mcp-Session-Id': 'invalid-session-id',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(deleteResponse.status).toBe(404);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('DELETE without Mcp-Session-Id returns 400', async () => {
|
|
331
|
+
bridge = new MCPWebBridgeNode({
|
|
332
|
+
name: 'Test Bridge',
|
|
333
|
+
description: 'Test',
|
|
334
|
+
port,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const deleteResponse = await fetch(`http://localhost:${port}`, {
|
|
338
|
+
method: 'DELETE',
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(deleteResponse.status).toBe(400);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('Remote MCP - SSE Stream', () => {
|
|
346
|
+
let bridge: MCPWebBridgeNode;
|
|
347
|
+
const port = 4603;
|
|
348
|
+
|
|
349
|
+
afterEach(async () => {
|
|
350
|
+
if (bridge) {
|
|
351
|
+
await bridge.close();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('GET with Accept: text/event-stream opens SSE connection', async () => {
|
|
356
|
+
bridge = new MCPWebBridgeNode({
|
|
357
|
+
name: 'Test Bridge',
|
|
358
|
+
description: 'Test',
|
|
359
|
+
port,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Initialize to get session ID
|
|
363
|
+
const initResponse = await mcpRequest(
|
|
364
|
+
port,
|
|
365
|
+
'initialize',
|
|
366
|
+
{ protocolVersion: '2024-11-05' },
|
|
367
|
+
{ Authorization: 'Bearer test-token' }
|
|
368
|
+
);
|
|
369
|
+
const sessionId = initResponse.headers.get('mcp-session-id')!;
|
|
370
|
+
|
|
371
|
+
// Open SSE connection
|
|
372
|
+
const controller = new AbortController();
|
|
373
|
+
const ssePromise = fetch(`http://localhost:${port}`, {
|
|
374
|
+
method: 'GET',
|
|
375
|
+
headers: {
|
|
376
|
+
Accept: 'text/event-stream',
|
|
377
|
+
'Mcp-Session-Id': sessionId,
|
|
378
|
+
},
|
|
379
|
+
signal: controller.signal,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Give it a moment to establish
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
384
|
+
|
|
385
|
+
// Abort the connection
|
|
386
|
+
controller.abort();
|
|
387
|
+
|
|
388
|
+
// The fetch should have started (we can't easily test streaming in this context)
|
|
389
|
+
try {
|
|
390
|
+
await ssePromise;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
// AbortError is expected
|
|
393
|
+
expect((error as Error).name).toBe('AbortError');
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('GET without Accept: text/event-stream returns server info', async () => {
|
|
398
|
+
bridge = new MCPWebBridgeNode({
|
|
399
|
+
name: 'Test Bridge',
|
|
400
|
+
description: 'Test',
|
|
401
|
+
port,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
405
|
+
method: 'GET',
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(response.status).toBe(200);
|
|
409
|
+
const body = await response.json();
|
|
410
|
+
expect(body.name).toBe('Test Bridge');
|
|
411
|
+
expect(body.description).toBe('Test');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('GET SSE without Mcp-Session-Id returns error event', async () => {
|
|
415
|
+
bridge = new MCPWebBridgeNode({
|
|
416
|
+
name: 'Test Bridge',
|
|
417
|
+
description: 'Test',
|
|
418
|
+
port,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
422
|
+
method: 'GET',
|
|
423
|
+
headers: {
|
|
424
|
+
Accept: 'text/event-stream',
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(response.status).toBe(200);
|
|
429
|
+
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
|
430
|
+
|
|
431
|
+
// Read the first event
|
|
432
|
+
const reader = response.body!.getReader();
|
|
433
|
+
const { value } = await reader.read();
|
|
434
|
+
const text = new TextDecoder().decode(value);
|
|
435
|
+
|
|
436
|
+
expect(text).toContain('data:');
|
|
437
|
+
expect(text).toContain('Mcp-Session-Id header required');
|
|
438
|
+
|
|
439
|
+
reader.cancel();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('Remote MCP - Tools List Changed Notification', () => {
|
|
444
|
+
let bridge: MCPWebBridgeNode;
|
|
445
|
+
let wsClient: WebSocket;
|
|
446
|
+
const port = 4604;
|
|
447
|
+
|
|
448
|
+
afterEach(async () => {
|
|
449
|
+
if (wsClient && wsClient.readyState === WebSocket.OPEN) {
|
|
450
|
+
wsClient.close();
|
|
451
|
+
}
|
|
452
|
+
if (bridge) {
|
|
453
|
+
await bridge.close();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('tool registration triggers notification to SSE stream', async () => {
|
|
458
|
+
bridge = new MCPWebBridgeNode({
|
|
459
|
+
name: 'Test Bridge',
|
|
460
|
+
description: 'Test',
|
|
461
|
+
port,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const authToken = 'test-token-notify';
|
|
465
|
+
|
|
466
|
+
// Initialize MCP session
|
|
467
|
+
const initResponse = await mcpRequest(
|
|
468
|
+
port,
|
|
469
|
+
'initialize',
|
|
470
|
+
{ protocolVersion: '2024-11-05' },
|
|
471
|
+
{ Authorization: `Bearer ${authToken}` }
|
|
472
|
+
);
|
|
473
|
+
const mcpSessionId = initResponse.headers.get('mcp-session-id')!;
|
|
474
|
+
|
|
475
|
+
// Open SSE stream and collect events
|
|
476
|
+
const receivedEvents: string[] = [];
|
|
477
|
+
const controller = new AbortController();
|
|
478
|
+
|
|
479
|
+
const ssePromise = (async () => {
|
|
480
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
481
|
+
method: 'GET',
|
|
482
|
+
headers: {
|
|
483
|
+
Accept: 'text/event-stream',
|
|
484
|
+
'Mcp-Session-Id': mcpSessionId,
|
|
485
|
+
},
|
|
486
|
+
signal: controller.signal,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const reader = response.body!.getReader();
|
|
490
|
+
const decoder = new TextDecoder();
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
while (true) {
|
|
494
|
+
const { done, value } = await reader.read();
|
|
495
|
+
if (done) break;
|
|
496
|
+
const text = decoder.decode(value);
|
|
497
|
+
receivedEvents.push(text);
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
// Stream was aborted, that's expected
|
|
501
|
+
}
|
|
502
|
+
})();
|
|
503
|
+
|
|
504
|
+
// Wait for SSE connection to establish
|
|
505
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
506
|
+
|
|
507
|
+
// Connect WebSocket client and register tool
|
|
508
|
+
wsClient = await createMockClient(port, 'session-notify-1');
|
|
509
|
+
await authenticateClient(wsClient, authToken);
|
|
510
|
+
|
|
511
|
+
// Small delay to ensure authentication is processed
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
513
|
+
|
|
514
|
+
// Register a tool - this should trigger notification
|
|
515
|
+
registerTool(wsClient, 'test_tool', 'A test tool');
|
|
516
|
+
|
|
517
|
+
// Wait for notification to be sent and received
|
|
518
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
519
|
+
|
|
520
|
+
// Abort SSE connection
|
|
521
|
+
controller.abort();
|
|
522
|
+
await ssePromise.catch(() => {});
|
|
523
|
+
|
|
524
|
+
// Check received events
|
|
525
|
+
const fullText = receivedEvents.join('');
|
|
526
|
+
expect(fullText).toContain('notifications/tools/list_changed');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('tool registration only notifies matching auth token', async () => {
|
|
530
|
+
bridge = new MCPWebBridgeNode({
|
|
531
|
+
name: 'Test Bridge',
|
|
532
|
+
description: 'Test',
|
|
533
|
+
port,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const authToken1 = 'test-token-1';
|
|
537
|
+
const authToken2 = 'test-token-2';
|
|
538
|
+
|
|
539
|
+
// Initialize MCP session for token1
|
|
540
|
+
const initResponse = await mcpRequest(
|
|
541
|
+
port,
|
|
542
|
+
'initialize',
|
|
543
|
+
{ protocolVersion: '2024-11-05' },
|
|
544
|
+
{ Authorization: `Bearer ${authToken1}` }
|
|
545
|
+
);
|
|
546
|
+
const mcpSessionId = initResponse.headers.get('mcp-session-id')!;
|
|
547
|
+
|
|
548
|
+
// Open SSE stream for token1's session and collect events
|
|
549
|
+
const receivedEvents: string[] = [];
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
|
|
552
|
+
const ssePromise = (async () => {
|
|
553
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
554
|
+
method: 'GET',
|
|
555
|
+
headers: {
|
|
556
|
+
Accept: 'text/event-stream',
|
|
557
|
+
'Mcp-Session-Id': mcpSessionId,
|
|
558
|
+
},
|
|
559
|
+
signal: controller.signal,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const reader = response.body!.getReader();
|
|
563
|
+
const decoder = new TextDecoder();
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
while (true) {
|
|
567
|
+
const { done, value } = await reader.read();
|
|
568
|
+
if (done) break;
|
|
569
|
+
const text = decoder.decode(value);
|
|
570
|
+
receivedEvents.push(text);
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
// Stream was aborted, that's expected
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
578
|
+
|
|
579
|
+
// Connect WebSocket client with DIFFERENT auth token
|
|
580
|
+
wsClient = await createMockClient(port, 'session-different-token');
|
|
581
|
+
await authenticateClient(wsClient, authToken2);
|
|
582
|
+
|
|
583
|
+
// Register tool with different token - should NOT notify token1's SSE
|
|
584
|
+
registerTool(wsClient, 'other_tool', 'Other tool');
|
|
585
|
+
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
587
|
+
|
|
588
|
+
controller.abort();
|
|
589
|
+
await ssePromise.catch(() => {});
|
|
590
|
+
|
|
591
|
+
const fullText = receivedEvents.join('');
|
|
592
|
+
|
|
593
|
+
// Should NOT contain tools/list_changed (different auth token)
|
|
594
|
+
expect(fullText).not.toContain('notifications/tools/list_changed');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('session disconnect triggers notification to SSE stream', async () => {
|
|
598
|
+
bridge = new MCPWebBridgeNode({
|
|
599
|
+
name: 'Test Bridge',
|
|
600
|
+
description: 'Test',
|
|
601
|
+
port,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const authToken = 'test-token-disconnect';
|
|
605
|
+
|
|
606
|
+
// Connect WebSocket client and register tool first
|
|
607
|
+
wsClient = await createMockClient(port, 'session-disconnect-1');
|
|
608
|
+
await authenticateClient(wsClient, authToken);
|
|
609
|
+
registerTool(wsClient, 'disconnect_tool', 'A tool');
|
|
610
|
+
|
|
611
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
612
|
+
|
|
613
|
+
// Initialize MCP session
|
|
614
|
+
const initResponse = await mcpRequest(
|
|
615
|
+
port,
|
|
616
|
+
'initialize',
|
|
617
|
+
{ protocolVersion: '2024-11-05' },
|
|
618
|
+
{ Authorization: `Bearer ${authToken}` }
|
|
619
|
+
);
|
|
620
|
+
const mcpSessionId = initResponse.headers.get('mcp-session-id')!;
|
|
621
|
+
|
|
622
|
+
// Open SSE stream and collect events
|
|
623
|
+
const receivedEvents: string[] = [];
|
|
624
|
+
const controller = new AbortController();
|
|
625
|
+
|
|
626
|
+
const ssePromise = (async () => {
|
|
627
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
628
|
+
method: 'GET',
|
|
629
|
+
headers: {
|
|
630
|
+
Accept: 'text/event-stream',
|
|
631
|
+
'Mcp-Session-Id': mcpSessionId,
|
|
632
|
+
},
|
|
633
|
+
signal: controller.signal,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const reader = response.body!.getReader();
|
|
637
|
+
const decoder = new TextDecoder();
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
while (true) {
|
|
641
|
+
const { done, value } = await reader.read();
|
|
642
|
+
if (done) break;
|
|
643
|
+
const text = decoder.decode(value);
|
|
644
|
+
receivedEvents.push(text);
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
// Stream was aborted
|
|
648
|
+
}
|
|
649
|
+
})();
|
|
650
|
+
|
|
651
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
652
|
+
|
|
653
|
+
// Disconnect the WebSocket client - this should trigger notification
|
|
654
|
+
wsClient.close();
|
|
655
|
+
|
|
656
|
+
// Wait for notification
|
|
657
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
658
|
+
|
|
659
|
+
controller.abort();
|
|
660
|
+
await ssePromise.catch(() => {});
|
|
661
|
+
|
|
662
|
+
const fullText = receivedEvents.join('');
|
|
663
|
+
expect(fullText).toContain('notifications/tools/list_changed');
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('Remote MCP - Capabilities', () => {
|
|
668
|
+
let bridge: MCPWebBridgeNode;
|
|
669
|
+
const port = 4605;
|
|
670
|
+
|
|
671
|
+
afterEach(async () => {
|
|
672
|
+
if (bridge) {
|
|
673
|
+
await bridge.close();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('initialize response includes listChanged capability', async () => {
|
|
678
|
+
bridge = new MCPWebBridgeNode({
|
|
679
|
+
name: 'Test Bridge',
|
|
680
|
+
description: 'Test',
|
|
681
|
+
port,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const { body } = await mcpRequest(
|
|
685
|
+
port,
|
|
686
|
+
'initialize',
|
|
687
|
+
{ protocolVersion: '2024-11-05' },
|
|
688
|
+
{ Authorization: 'Bearer test-token' }
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
expect(body).toMatchObject({
|
|
692
|
+
result: {
|
|
693
|
+
capabilities: {
|
|
694
|
+
tools: { listChanged: true },
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe('Remote MCP - Server Info', () => {
|
|
702
|
+
let bridge: MCPWebBridgeNode;
|
|
703
|
+
const port = 4606;
|
|
704
|
+
|
|
705
|
+
afterEach(async () => {
|
|
706
|
+
if (bridge) {
|
|
707
|
+
await bridge.close();
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('GET / returns server info without auth', async () => {
|
|
712
|
+
bridge = new MCPWebBridgeNode({
|
|
713
|
+
name: 'Test Bridge',
|
|
714
|
+
description: 'A test bridge server',
|
|
715
|
+
port,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const response = await fetch(`http://localhost:${port}/`);
|
|
719
|
+
expect(response.status).toBe(200);
|
|
720
|
+
|
|
721
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
722
|
+
expect(body.name).toBe('Test Bridge');
|
|
723
|
+
expect(body.description).toBe('A test bridge server');
|
|
724
|
+
expect(typeof body.version).toBe('string');
|
|
725
|
+
// No icon configured, so it should not be present
|
|
726
|
+
expect(body.icon).toBeUndefined();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('GET / includes icon when configured as data URI', async () => {
|
|
730
|
+
const testIcon = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=';
|
|
731
|
+
bridge = new MCPWebBridgeNode({
|
|
732
|
+
name: 'Test Bridge',
|
|
733
|
+
description: 'Test',
|
|
734
|
+
port,
|
|
735
|
+
icon: testIcon,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const response = await fetch(`http://localhost:${port}/`);
|
|
739
|
+
expect(response.status).toBe(200);
|
|
740
|
+
|
|
741
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
742
|
+
expect(body.icon).toBe(testIcon);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('initialize response includes icon when configured', async () => {
|
|
746
|
+
const testIcon = 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=';
|
|
747
|
+
bridge = new MCPWebBridgeNode({
|
|
748
|
+
name: 'Test Bridge',
|
|
749
|
+
description: 'Test',
|
|
750
|
+
port,
|
|
751
|
+
icon: testIcon,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
const { body } = await mcpRequest(
|
|
755
|
+
port,
|
|
756
|
+
'initialize',
|
|
757
|
+
{ protocolVersion: '2024-11-05' },
|
|
758
|
+
{ Authorization: 'Bearer test-token' }
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
expect(body).toMatchObject({
|
|
762
|
+
result: {
|
|
763
|
+
serverInfo: {
|
|
764
|
+
name: 'Test Bridge',
|
|
765
|
+
icon: testIcon,
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
});
|