@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
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @mcp-web/bridge - MCP Web Bridge for connecting web frontends to AI agents.
3
+ *
4
+ * This package provides a runtime-agnostic bridge that mediates between
5
+ * web frontends and AI agents via the Model Context Protocol (MCP).
6
+ *
7
+ * @example Node.js (recommended)
8
+ * ```typescript
9
+ * import { MCPWebBridgeNode } from '@mcp-web/bridge';
10
+ *
11
+ * const bridge = new MCPWebBridgeNode({
12
+ * name: 'My App',
13
+ * description: 'My awesome app',
14
+ * port: 3001,
15
+ * });
16
+ * ```
17
+ *
18
+ * @example Custom adapter (advanced)
19
+ * ```typescript
20
+ * import { MCPWebBridge } from '@mcp-web/bridge';
21
+ *
22
+ * const core = new MCPWebBridge(config);
23
+ * const handlers = core.getHandlers();
24
+ * // Wire handlers to your runtime's WebSocket/HTTP servers
25
+ * ```
26
+ */
27
+
28
+ // Re-export types from @mcp-web/types
29
+ export type {
30
+ QueryAcceptedMessage,
31
+ QueryCompleteBridgeMessage,
32
+ QueryCompleteClientMessage,
33
+ QueryFailureMessage,
34
+ QueryMessage,
35
+ QueryProgressMessage,
36
+ } from '@mcp-web/types';
37
+
38
+ export {
39
+ InternalErrorCode,
40
+ InvalidAuthenticationErrorCode,
41
+ MissingAuthenticationErrorCode,
42
+ QueryNotActiveErrorCode,
43
+ QueryNotFoundErrorCode,
44
+ UnknownMethodErrorCode,
45
+ } from '@mcp-web/types';
46
+ export type { MCPWebBridgeBunConfig, MCPWebBridgeDenoConfig, MCPWebBridgeNodeConfig, MCPWebBridgeNodeSSLConfig, MCPWebBridgePartyConfig } from './adapters/index.js';
47
+ // Adapters
48
+ // Deno adapter
49
+ // Bun adapter
50
+ // PartyKit adapter
51
+ export { AlarmScheduler, Bridge, createPartyKitBridge, MCPWebBridgeBun, MCPWebBridgeDeno, MCPWebBridgeNode, MCPWebBridgeParty } from './adapters/index.js';
52
+ // Core bridge (runtime-agnostic)
53
+ // Backwards compatibility: re-export MCPWebBridge as the old name
54
+ // Note: The old MCPWebBridge class used dual ports. The new architecture
55
+ // uses single port via adapters. For migration, use MCPWebBridgeNode.
56
+ /**
57
+ * @deprecated Use MCPWebBridgeNode for new code. This export exists for backwards compatibility.
58
+ */
59
+ export { MCPWebBridge, MCPWebBridge as MCPWebBridgeCore } from './core.js';
60
+ // Runtime abstractions (for custom adapter authors)
61
+ export type {
62
+ BridgeAdapterConfig,
63
+ BridgeHandlers,
64
+ HttpRequest,
65
+ HttpResponse,Scheduler,
66
+ WebSocketConnection
67
+ } from './runtime/index.js';
68
+ export {
69
+ createHttpResponse,
70
+ jsonResponse,NoopScheduler,
71
+ readyStateToString,TimerScheduler,
72
+ WebSocketReadyState
73
+ } from './runtime/index.js';
74
+ // Legacy types (for backwards compatibility)
75
+ export type {
76
+ ActivityMessage,
77
+ AuthenticatedMessage,
78
+ AuthenticateMessage,
79
+ BridgeMessage,
80
+ FrontendMessage,
81
+ QueryTracking,
82
+ RegisterResourceMessage,
83
+ RegisterToolMessage,
84
+ ResourceReadMessage,
85
+ ResourceResponseMessage,
86
+ ToolCallMessage,
87
+ ToolRegistrationErrorMessage,
88
+ ToolResponseMessage,
89
+ TrackedToolCall,
90
+ } from './types.js';
@@ -0,0 +1,436 @@
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(JSON.stringify({
39
+ type: 'authenticate',
40
+ authToken,
41
+ origin: 'http://localhost:3000',
42
+ pageTitle: 'Test Page',
43
+ userAgent: 'Test Agent',
44
+ timestamp: Date.now()
45
+ }));
46
+
47
+ await waitForMessage(ws, 'authenticated');
48
+ }
49
+
50
+ describe('Session Limits', () => {
51
+ let bridge: MCPWebBridgeNode;
52
+ let clients: WebSocket[] = [];
53
+ const port = 4101;
54
+
55
+ afterEach(async () => {
56
+ // Close all clients
57
+ for (const client of clients) {
58
+ if (client.readyState === WebSocket.OPEN) {
59
+ client.close();
60
+ }
61
+ }
62
+ clients = [];
63
+
64
+ // Close bridge
65
+ if (bridge) {
66
+ await bridge.close();
67
+ }
68
+ });
69
+
70
+ test('rejects new session when limit exceeded (reject mode)', async () => {
71
+ bridge = new MCPWebBridgeNode({
72
+ name: 'Test Bridge',
73
+ description: 'Test',
74
+ port,
75
+ maxSessionsPerToken: 2,
76
+ onSessionLimitExceeded: 'reject'
77
+ });
78
+
79
+ const authToken = 'test-token-reject';
80
+
81
+ // Create and authenticate 2 sessions (at limit)
82
+ const client1 = await createMockClient(port, 'session-1');
83
+ clients.push(client1);
84
+ await authenticateClient(client1, authToken);
85
+
86
+ const client2 = await createMockClient(port, 'session-2');
87
+ clients.push(client2);
88
+ await authenticateClient(client2, authToken);
89
+
90
+ // Try to create a 3rd session - should be rejected
91
+ const client3 = await createMockClient(port, 'session-3');
92
+ clients.push(client3);
93
+
94
+ const closePromise = new Promise<{ code: number; reason: string }>((resolve) => {
95
+ client3.on('close', (code, reason) => {
96
+ resolve({ code, reason: reason.toString() });
97
+ });
98
+ });
99
+
100
+ // Send authenticate - should fail
101
+ client3.send(JSON.stringify({
102
+ type: 'authenticate',
103
+ authToken,
104
+ origin: 'http://localhost:3000',
105
+ timestamp: Date.now()
106
+ }));
107
+
108
+ // Wait for the authentication-failed message and close
109
+ const failedMessage = await waitForMessage<{ type: string; code: string }>(client3, 'authentication-failed');
110
+ expect(failedMessage.code).toBe('SessionLimitExceeded');
111
+
112
+ const closeResult = await closePromise;
113
+ expect(closeResult.code).toBe(1008);
114
+ expect(closeResult.reason).toBe('Session limit exceeded');
115
+ });
116
+
117
+ test('closes oldest session when limit exceeded (close_oldest mode)', async () => {
118
+ bridge = new MCPWebBridgeNode({
119
+ name: 'Test Bridge',
120
+ description: 'Test',
121
+ port,
122
+ maxSessionsPerToken: 2,
123
+ onSessionLimitExceeded: 'close_oldest'
124
+ });
125
+
126
+ const authToken = 'test-token-close-oldest';
127
+
128
+ // Create and authenticate first session
129
+ const client1 = await createMockClient(port, 'session-oldest-1');
130
+ clients.push(client1);
131
+ await authenticateClient(client1, authToken);
132
+
133
+ // Track if client1 gets closed
134
+ const client1ClosePromise = new Promise<{ code: number }>((resolve) => {
135
+ client1.on('close', (code) => resolve({ code }));
136
+ });
137
+
138
+ // Small delay to ensure different connectedAt timestamps
139
+ await new Promise(resolve => setTimeout(resolve, 50));
140
+
141
+ // Create and authenticate second session
142
+ const client2 = await createMockClient(port, 'session-oldest-2');
143
+ clients.push(client2);
144
+ await authenticateClient(client2, authToken);
145
+
146
+ // Create and authenticate third session - should close the oldest (client1)
147
+ const client3 = await createMockClient(port, 'session-oldest-3');
148
+ clients.push(client3);
149
+ await authenticateClient(client3, authToken);
150
+
151
+ // client1 should have been closed
152
+ const closeResult = await client1ClosePromise;
153
+ expect(closeResult.code).toBe(1008);
154
+
155
+ // client2 and client3 should still be open
156
+ expect(client2.readyState).toBe(WebSocket.OPEN);
157
+ expect(client3.readyState).toBe(WebSocket.OPEN);
158
+ });
159
+
160
+ test('different tokens have separate session limits', async () => {
161
+ bridge = new MCPWebBridgeNode({
162
+ name: 'Test Bridge',
163
+ description: 'Test',
164
+ port,
165
+ maxSessionsPerToken: 1,
166
+ onSessionLimitExceeded: 'reject'
167
+ });
168
+
169
+ // Create sessions with different tokens - both should succeed
170
+ const client1 = await createMockClient(port, 'session-token-a');
171
+ clients.push(client1);
172
+ await authenticateClient(client1, 'token-a');
173
+
174
+ const client2 = await createMockClient(port, 'session-token-b');
175
+ clients.push(client2);
176
+ await authenticateClient(client2, 'token-b');
177
+
178
+ // Both should be connected
179
+ expect(client1.readyState).toBe(WebSocket.OPEN);
180
+ expect(client2.readyState).toBe(WebSocket.OPEN);
181
+ });
182
+
183
+ test('no limit when maxSessionsPerToken is not set', async () => {
184
+ bridge = new MCPWebBridgeNode({
185
+ name: 'Test Bridge',
186
+ description: 'Test',
187
+ port
188
+ // No maxSessionsPerToken
189
+ });
190
+
191
+ const authToken = 'test-token-no-limit';
192
+
193
+ // Create many sessions - all should succeed
194
+ for (let i = 0; i < 5; i++) {
195
+ const client = await createMockClient(port, `session-no-limit-${i}`);
196
+ clients.push(client);
197
+ await authenticateClient(client, authToken);
198
+ expect(client.readyState).toBe(WebSocket.OPEN);
199
+ }
200
+ });
201
+ });
202
+
203
+ describe('Query Limits', () => {
204
+ let bridge: MCPWebBridgeNode;
205
+ let clients: WebSocket[] = [];
206
+ const port = 4201;
207
+ let originalFetch: typeof fetch;
208
+
209
+ beforeEach(() => {
210
+ // Save original fetch
211
+ originalFetch = globalThis.fetch;
212
+ });
213
+
214
+ afterEach(async () => {
215
+ // Restore original fetch
216
+ globalThis.fetch = originalFetch;
217
+
218
+ for (const client of clients) {
219
+ if (client.readyState === WebSocket.OPEN) {
220
+ client.close();
221
+ }
222
+ }
223
+ clients = [];
224
+
225
+ if (bridge) {
226
+ await bridge.close();
227
+ }
228
+ });
229
+
230
+ test('rejects query when limit exceeded', async () => {
231
+ // Mock fetch to return a never-resolving promise (keeps queries "in flight")
232
+ globalThis.fetch = () => new Promise(() => {});
233
+
234
+ bridge = new MCPWebBridgeNode({
235
+ name: 'Test Bridge',
236
+ description: 'Test',
237
+ port,
238
+ maxInFlightQueriesPerToken: 2,
239
+ agentUrl: 'localhost:9999'
240
+ });
241
+
242
+ const authToken = 'test-token-query-limit';
243
+
244
+ const client = await createMockClient(port, 'session-query-limit');
245
+ clients.push(client);
246
+ await authenticateClient(client, authToken);
247
+
248
+ // Send first query - will hang because fetch never resolves
249
+ client.send(JSON.stringify({
250
+ type: 'query',
251
+ uuid: 'query-1',
252
+ prompt: 'Test query 1',
253
+ context: [],
254
+ tools: []
255
+ }));
256
+
257
+ // Small delay to ensure query is registered
258
+ await new Promise(resolve => setTimeout(resolve, 50));
259
+
260
+ // Send second query
261
+ client.send(JSON.stringify({
262
+ type: 'query',
263
+ uuid: 'query-2',
264
+ prompt: 'Test query 2',
265
+ context: [],
266
+ tools: []
267
+ }));
268
+
269
+ // Small delay to ensure query is registered
270
+ await new Promise(resolve => setTimeout(resolve, 50));
271
+
272
+ // Send third query - should be rejected due to limit (2 queries already in flight)
273
+ client.send(JSON.stringify({
274
+ type: 'query',
275
+ uuid: 'query-3',
276
+ prompt: 'Test query 3',
277
+ context: [],
278
+ tools: []
279
+ }));
280
+
281
+ // Wait for failure message for query-3
282
+ const failureMessage = await waitForMessage<{ type: string; uuid: string; error: string }>(
283
+ client,
284
+ 'query_failure'
285
+ );
286
+
287
+ expect(failureMessage.uuid).toBe('query-3');
288
+ expect(failureMessage.error).toContain('Query limit exceeded');
289
+ });
290
+
291
+ test('no limit when maxInFlightQueriesPerToken is not set', async () => {
292
+ // Mock fetch to return a never-resolving promise
293
+ globalThis.fetch = () => new Promise(() => {});
294
+
295
+ bridge = new MCPWebBridgeNode({
296
+ name: 'Test Bridge',
297
+ description: 'Test',
298
+ port,
299
+ agentUrl: 'localhost:9999'
300
+ // No maxInFlightQueriesPerToken
301
+ });
302
+
303
+ const authToken = 'test-token-no-query-limit';
304
+
305
+ const client = await createMockClient(port, 'session-no-query-limit');
306
+ clients.push(client);
307
+ await authenticateClient(client, authToken);
308
+
309
+ // Send many queries - all should be accepted since no limit is set
310
+ for (let i = 0; i < 10; i++) {
311
+ client.send(JSON.stringify({
312
+ type: 'query',
313
+ uuid: `query-unlimited-${i}`,
314
+ prompt: `Test query ${i}`,
315
+ context: [],
316
+ tools: []
317
+ }));
318
+ }
319
+
320
+ // Wait a bit - no query_failure messages should be received
321
+ await new Promise(resolve => setTimeout(resolve, 200));
322
+
323
+ // If we got here without the test failing, no limit errors were sent
324
+ // The queries are still "in flight" (waiting on the mock fetch)
325
+ });
326
+ });
327
+
328
+ describe('Session Timeout', () => {
329
+ let bridge: MCPWebBridgeNode;
330
+ let clients: WebSocket[] = [];
331
+ const port = 4301;
332
+
333
+ afterEach(async () => {
334
+ for (const client of clients) {
335
+ if (client.readyState === WebSocket.OPEN) {
336
+ client.close();
337
+ }
338
+ }
339
+ clients = [];
340
+
341
+ if (bridge) {
342
+ await bridge.close();
343
+ }
344
+ });
345
+
346
+ test('session expires after maxDuration', async () => {
347
+ // Use a very short duration for testing (100ms)
348
+ // Note: The actual checker runs every 60s, so we need to test differently
349
+ // We'll verify the config is accepted and the timeout checker starts
350
+ bridge = new MCPWebBridgeNode({
351
+ name: 'Test Bridge',
352
+ description: 'Test',
353
+ port,
354
+ sessionMaxDurationMs: 100 // Very short for testing
355
+ });
356
+
357
+ const client = await createMockClient(port, 'session-timeout');
358
+ clients.push(client);
359
+ await authenticateClient(client, 'test-token-timeout');
360
+
361
+ // The session should be open initially
362
+ expect(client.readyState).toBe(WebSocket.OPEN);
363
+
364
+ // Note: In a real test we'd wait for the timeout interval (60s) to fire
365
+ // For unit testing, we just verify the bridge accepts the config
366
+ // Integration tests would test the actual timeout behavior
367
+ });
368
+
369
+ test('no timeout when sessionMaxDurationMs is not set', async () => {
370
+ bridge = new MCPWebBridgeNode({
371
+ name: 'Test Bridge',
372
+ description: 'Test',
373
+ port
374
+ // No sessionMaxDurationMs
375
+ });
376
+
377
+ const client = await createMockClient(port, 'session-no-timeout');
378
+ clients.push(client);
379
+ await authenticateClient(client, 'test-token-no-timeout');
380
+
381
+ // Session should stay open
382
+ expect(client.readyState).toBe(WebSocket.OPEN);
383
+ });
384
+ });
385
+
386
+ describe('Config Schema', () => {
387
+ test('accepts all new limit properties', async () => {
388
+ // This should not throw
389
+ const bridge = new MCPWebBridgeNode({
390
+ name: 'Test Bridge',
391
+ description: 'Test',
392
+ port: 4401,
393
+ maxSessionsPerToken: 5,
394
+ onSessionLimitExceeded: 'close_oldest',
395
+ maxInFlightQueriesPerToken: 10,
396
+ sessionMaxDurationMs: 3600000
397
+ });
398
+
399
+ await bridge.close();
400
+ });
401
+
402
+ test('defaults onSessionLimitExceeded to reject', async () => {
403
+ const bridge = new MCPWebBridgeNode({
404
+ name: 'Test Bridge',
405
+ description: 'Test',
406
+ port: 4501,
407
+ maxSessionsPerToken: 1
408
+ // onSessionLimitExceeded not specified
409
+ });
410
+
411
+ // Create first session
412
+ const client1 = await createMockClient(4501, 'session-default-1');
413
+ await authenticateClient(client1, 'token-default');
414
+
415
+ // Second session should be rejected (default behavior)
416
+ const client2 = await createMockClient(4501, 'session-default-2');
417
+
418
+ const closePromise = new Promise<{ code: number }>((resolve) => {
419
+ client2.on('close', (code) => resolve({ code }));
420
+ });
421
+
422
+ client2.send(JSON.stringify({
423
+ type: 'authenticate',
424
+ authToken: 'token-default',
425
+ origin: 'http://localhost:3000',
426
+ timestamp: Date.now()
427
+ }));
428
+
429
+ const closeResult = await closePromise;
430
+ expect(closeResult.code).toBe(1008);
431
+
432
+ client1.close();
433
+ client2.close();
434
+ await bridge.close();
435
+ });
436
+ });