@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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +311 -0
  3. package/dist/adapters/bun.d.ts +95 -0
  4. package/dist/adapters/bun.d.ts.map +1 -0
  5. package/dist/adapters/bun.js +286 -0
  6. package/dist/adapters/bun.js.map +1 -0
  7. package/dist/adapters/deno.d.ts +89 -0
  8. package/dist/adapters/deno.d.ts.map +1 -0
  9. package/dist/adapters/deno.js +249 -0
  10. package/dist/adapters/deno.js.map +1 -0
  11. package/dist/adapters/index.d.ts +21 -0
  12. package/dist/adapters/index.d.ts.map +1 -0
  13. package/dist/adapters/index.js +21 -0
  14. package/dist/adapters/index.js.map +1 -0
  15. package/dist/adapters/node.d.ts +112 -0
  16. package/dist/adapters/node.d.ts.map +1 -0
  17. package/dist/adapters/node.js +309 -0
  18. package/dist/adapters/node.js.map +1 -0
  19. package/dist/adapters/partykit.d.ts +153 -0
  20. package/dist/adapters/partykit.d.ts.map +1 -0
  21. package/dist/adapters/partykit.js +372 -0
  22. package/dist/adapters/partykit.js.map +1 -0
  23. package/dist/bridge.d.ts +38 -0
  24. package/dist/bridge.d.ts.map +1 -0
  25. package/dist/bridge.js +1004 -0
  26. package/dist/bridge.js.map +1 -0
  27. package/dist/core.d.ts +75 -0
  28. package/dist/core.d.ts.map +1 -0
  29. package/dist/core.js +1508 -0
  30. package/dist/core.js.map +1 -0
  31. package/dist/index.d.ts +38 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +42 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/runtime/index.d.ts +11 -0
  36. package/dist/runtime/index.d.ts.map +1 -0
  37. package/dist/runtime/index.js +9 -0
  38. package/dist/runtime/index.js.map +1 -0
  39. package/dist/runtime/scheduler.d.ts +69 -0
  40. package/dist/runtime/scheduler.d.ts.map +1 -0
  41. package/dist/runtime/scheduler.js +88 -0
  42. package/dist/runtime/scheduler.js.map +1 -0
  43. package/dist/runtime/types.d.ts +144 -0
  44. package/dist/runtime/types.d.ts.map +1 -0
  45. package/dist/runtime/types.js +82 -0
  46. package/dist/runtime/types.js.map +1 -0
  47. package/dist/schemas.d.ts +6 -0
  48. package/dist/schemas.d.ts.map +1 -0
  49. package/dist/schemas.js +6 -0
  50. package/dist/schemas.js.map +1 -0
  51. package/dist/types.d.ts +130 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/package.json +28 -0
  56. package/src/adapters/bun.ts +354 -0
  57. package/src/adapters/deno.ts +282 -0
  58. package/src/adapters/index.ts +28 -0
  59. package/src/adapters/node.ts +385 -0
  60. package/src/adapters/partykit.ts +482 -0
  61. package/src/bridge.test.ts +64 -0
  62. package/src/core.ts +2176 -0
  63. package/src/index.ts +90 -0
  64. package/src/limits.test.ts +436 -0
  65. package/src/remote-mcp.test.ts +770 -0
  66. package/src/runtime/index.ts +24 -0
  67. package/src/runtime/scheduler.ts +130 -0
  68. package/src/runtime/types.ts +229 -0
  69. package/src/schemas.ts +6 -0
  70. package/src/session-naming.test.ts +443 -0
  71. package/src/types.ts +180 -0
  72. 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
+ });