@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,482 @@
1
+ /**
2
+ * MCPWebBridgeParty - PartyKit adapter for the MCP Web Bridge.
3
+ *
4
+ * Enables deployment to Cloudflare's edge network via PartyKit.
5
+ * Uses PartyKit's Party.Server interface with Durable Objects for state management.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * // server.ts - PartyKit server entry point
10
+ * import { createPartyKitBridge } from '@mcp-web/bridge';
11
+ *
12
+ * export default createPartyKitBridge({
13
+ * name: 'My App',
14
+ * description: 'My awesome app on the edge',
15
+ * });
16
+ * ```
17
+ *
18
+ * @example With partykit.json configuration
19
+ * ```json
20
+ * {
21
+ * "name": "my-mcp-bridge",
22
+ * "main": "server.ts",
23
+ * "compatibility_date": "2024-01-01"
24
+ * }
25
+ * ```
26
+ *
27
+ * @example Deploy to PartyKit
28
+ * ```bash
29
+ * npx partykit deploy
30
+ * ```
31
+ *
32
+ * @remarks
33
+ * PartyKit provides:
34
+ * - Global edge deployment via Cloudflare
35
+ * - Durable Objects for stateful WebSocket handling
36
+ * - Hibernation support for cost efficiency
37
+ * - Alarms for scheduled tasks (used instead of setInterval)
38
+ *
39
+ * Key differences from other adapters:
40
+ * - No explicit port configuration (managed by PartyKit/Cloudflare)
41
+ * - Uses `Party.storage.setAlarm()` for session timeout checks
42
+ * - State can persist across hibernation cycles
43
+ *
44
+ * @see https://docs.partykit.io/
45
+ * @see https://docs.partykit.io/guides/scheduling-tasks-with-alarms/
46
+ */
47
+
48
+ import type { MCPWebConfig } from '@mcp-web/types';
49
+ import { MCPWebBridge } from '../core.js';
50
+ import type { Scheduler } from '../runtime/scheduler.js';
51
+ import type { HttpRequest, HttpResponse, WebSocketConnection } from '../runtime/types.js';
52
+ import { isSSEResponse } from '../runtime/types.js';
53
+
54
+ // ============================================================================
55
+ // PartyKit Type Definitions (simplified for stub)
56
+ // In production, import from 'partykit/server'
57
+ // ============================================================================
58
+
59
+ /**
60
+ * PartyKit connection interface (simplified).
61
+ * @see https://docs.partykit.io/reference/partyserver-api/
62
+ */
63
+ interface PartyConnection {
64
+ id: string;
65
+ send(message: string | ArrayBuffer): void;
66
+ close(code?: number, reason?: string): void;
67
+ readyState: number;
68
+ }
69
+
70
+ /**
71
+ * PartyKit room interface (simplified).
72
+ */
73
+ interface PartyRoom {
74
+ id: string;
75
+ storage: {
76
+ get<T>(key: string): Promise<T | undefined>;
77
+ put<T>(key: string, value: T): Promise<void>;
78
+ delete(key: string): Promise<boolean>;
79
+ setAlarm(time: number | Date): Promise<void>;
80
+ getAlarm(): Promise<number | null>;
81
+ deleteAlarm(): Promise<void>;
82
+ };
83
+ broadcast(message: string, exclude?: string[]): void;
84
+ }
85
+
86
+ /**
87
+ * PartyKit connection context (simplified).
88
+ */
89
+ interface PartyConnectionContext {
90
+ request: Request;
91
+ }
92
+
93
+ /**
94
+ * PartyKit server interface that bridge implements.
95
+ */
96
+ interface PartyServer {
97
+ onConnect?(connection: PartyConnection, ctx: PartyConnectionContext): void | Promise<void>;
98
+ onMessage?(message: string, sender: PartyConnection): void | Promise<void>;
99
+ onClose?(connection: PartyConnection): void | Promise<void>;
100
+ onError?(connection: PartyConnection, error: Error): void | Promise<void>;
101
+ onRequest?(request: Request): Response | Promise<Response>;
102
+ onAlarm?(): void | Promise<void>;
103
+ }
104
+
105
+ // ============================================================================
106
+ // Configuration
107
+ // ============================================================================
108
+
109
+ /**
110
+ * Configuration for the PartyKit bridge adapter.
111
+ * Note: Port is not configurable - PartyKit manages this.
112
+ */
113
+ export interface MCPWebBridgePartyConfig extends Omit<MCPWebConfig, 'bridgeUrl'> {
114
+ /**
115
+ * Session timeout check interval in milliseconds.
116
+ * PartyKit uses alarms instead of setInterval, so this determines
117
+ * how often the alarm fires to check for expired sessions.
118
+ * Default: 60000 (1 minute)
119
+ */
120
+ sessionCheckIntervalMs?: number;
121
+ }
122
+
123
+ // ============================================================================
124
+ // Alarm-based Scheduler
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Scheduler implementation using PartyKit alarms.
129
+ *
130
+ * PartyKit only supports one alarm at a time per room, so this scheduler
131
+ * tracks multiple scheduled callbacks and uses the alarm for the soonest one.
132
+ *
133
+ * @see https://docs.partykit.io/guides/scheduling-tasks-with-alarms/
134
+ */
135
+ export class AlarmScheduler implements Scheduler {
136
+ #room: PartyRoom;
137
+ #callbacks = new Map<string, { callback: () => void; fireAt: number }>();
138
+ #intervals = new Map<string, { callback: () => void; intervalMs: number; nextFireAt: number }>();
139
+ #nextAlarmId: string | null = null;
140
+
141
+ constructor(room: PartyRoom) {
142
+ this.#room = room;
143
+ }
144
+
145
+ schedule(callback: () => void, delayMs: number): string {
146
+ const id = crypto.randomUUID();
147
+ const fireAt = Date.now() + delayMs;
148
+ this.#callbacks.set(id, { callback, fireAt });
149
+ this.#updateAlarm();
150
+ return id;
151
+ }
152
+
153
+ cancel(id: string): void {
154
+ this.#callbacks.delete(id);
155
+ this.#updateAlarm();
156
+ }
157
+
158
+ scheduleInterval(callback: () => void, intervalMs: number): string {
159
+ const id = crypto.randomUUID();
160
+ const nextFireAt = Date.now() + intervalMs;
161
+ this.#intervals.set(id, { callback, intervalMs, nextFireAt });
162
+ this.#updateAlarm();
163
+ return id;
164
+ }
165
+
166
+ cancelInterval(id: string): void {
167
+ this.#intervals.delete(id);
168
+ this.#updateAlarm();
169
+ }
170
+
171
+ dispose(): void {
172
+ this.#callbacks.clear();
173
+ this.#intervals.clear();
174
+ this.#room.storage.deleteAlarm().catch(() => {});
175
+ }
176
+
177
+ /**
178
+ * Called by the PartyKit server's onAlarm() handler.
179
+ * Executes due callbacks and reschedules the next alarm.
180
+ */
181
+ async handleAlarm(): Promise<void> {
182
+ const now = Date.now();
183
+
184
+ // Execute due one-time callbacks
185
+ for (const [id, { callback, fireAt }] of this.#callbacks) {
186
+ if (fireAt <= now) {
187
+ this.#callbacks.delete(id);
188
+ try {
189
+ callback();
190
+ } catch (error) {
191
+ console.error('Alarm callback error:', error);
192
+ }
193
+ }
194
+ }
195
+
196
+ // Execute due interval callbacks and reschedule them
197
+ for (const [id, interval] of this.#intervals) {
198
+ if (interval.nextFireAt <= now) {
199
+ interval.nextFireAt = now + interval.intervalMs;
200
+ try {
201
+ interval.callback();
202
+ } catch (error) {
203
+ console.error('Interval callback error:', error);
204
+ }
205
+ }
206
+ }
207
+
208
+ // Schedule next alarm
209
+ await this.#updateAlarm();
210
+ }
211
+
212
+ async #updateAlarm(): Promise<void> {
213
+ // Find the soonest scheduled callback
214
+ let soonest: number | null = null;
215
+
216
+ for (const { fireAt } of this.#callbacks.values()) {
217
+ if (soonest === null || fireAt < soonest) {
218
+ soonest = fireAt;
219
+ }
220
+ }
221
+
222
+ for (const { nextFireAt } of this.#intervals.values()) {
223
+ if (soonest === null || nextFireAt < soonest) {
224
+ soonest = nextFireAt;
225
+ }
226
+ }
227
+
228
+ if (soonest !== null) {
229
+ await this.#room.storage.setAlarm(soonest);
230
+ } else {
231
+ await this.#room.storage.deleteAlarm();
232
+ }
233
+ }
234
+ }
235
+
236
+ // ============================================================================
237
+ // WebSocket Wrapper
238
+ // ============================================================================
239
+
240
+ /**
241
+ * Extended WebSocket connection with internal dispatch method.
242
+ */
243
+ interface PartyWebSocketConnection extends WebSocketConnection {
244
+ _dispatchMessage(data: string): void;
245
+ }
246
+
247
+ /**
248
+ * Wraps a PartyKit Connection in our runtime-agnostic interface.
249
+ */
250
+ function wrapPartyConnection(connection: PartyConnection): PartyWebSocketConnection {
251
+ const messageHandlers = new Set<(data: string) => void>();
252
+
253
+ return {
254
+ send(data: string): void {
255
+ if (connection.readyState === 1) {
256
+ // OPEN
257
+ connection.send(data);
258
+ }
259
+ },
260
+
261
+ close(code?: number, reason?: string): void {
262
+ connection.close(code, reason);
263
+ },
264
+
265
+ get readyState(): 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED' {
266
+ switch (connection.readyState) {
267
+ case 0:
268
+ return 'CONNECTING';
269
+ case 1:
270
+ return 'OPEN';
271
+ case 2:
272
+ return 'CLOSING';
273
+ case 3:
274
+ default:
275
+ return 'CLOSED';
276
+ }
277
+ },
278
+
279
+ onMessage(handler: (data: string) => void): void {
280
+ messageHandlers.add(handler);
281
+ },
282
+
283
+ offMessage(handler: (data: string) => void): void {
284
+ messageHandlers.delete(handler);
285
+ },
286
+
287
+ // Internal: called by the PartyKit server to dispatch messages
288
+ _dispatchMessage(data: string): void {
289
+ for (const handler of messageHandlers) {
290
+ handler(data);
291
+ }
292
+ },
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Wraps a PartyKit/Cloudflare Request in our runtime-agnostic HttpRequest interface.
298
+ */
299
+ function wrapPartyRequest(req: Request): HttpRequest {
300
+ return {
301
+ method: req.method,
302
+ url: req.url,
303
+ headers: {
304
+ get(name: string): string | null {
305
+ return req.headers.get(name);
306
+ },
307
+ },
308
+ text(): Promise<string> {
309
+ return req.text();
310
+ },
311
+ };
312
+ }
313
+
314
+ // ============================================================================
315
+ // PartyKit Bridge Server
316
+ // ============================================================================
317
+
318
+ /**
319
+ * Creates a PartyKit-compatible bridge server class.
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * // server.ts
324
+ * import { createPartyKitBridge } from '@mcp-web/bridge';
325
+ *
326
+ * export default createPartyKitBridge({
327
+ * name: 'My Bridge',
328
+ * description: 'MCP Web bridge on the edge',
329
+ * });
330
+ * ```
331
+ */
332
+ export function createPartyKitBridge(config: MCPWebBridgePartyConfig): new (room: PartyRoom) => PartyServer {
333
+ return class MCPWebBridgeParty implements PartyServer {
334
+ #room: PartyRoom;
335
+ #core: MCPWebBridge;
336
+ #scheduler: AlarmScheduler;
337
+ #connections = new Map<string, PartyWebSocketConnection>();
338
+ #connectionSessionIds = new Map<string, string>(); // connectionId -> sessionId
339
+
340
+ constructor(room: PartyRoom) {
341
+ this.#room = room;
342
+ this.#scheduler = new AlarmScheduler(room);
343
+ this.#core = new MCPWebBridge(
344
+ config,
345
+ this.#scheduler
346
+ );
347
+
348
+ console.log(`🌉 MCP Web Bridge (PartyKit) initialized for room: ${room.id}`);
349
+ }
350
+
351
+ /**
352
+ * Handle new WebSocket connections.
353
+ */
354
+ async onConnect(connection: PartyConnection, ctx: PartyConnectionContext): Promise<void> {
355
+ const url = new URL(ctx.request.url);
356
+ const sessionId = url.searchParams.get('session');
357
+
358
+ if (!sessionId) {
359
+ connection.close(1008, 'Missing session parameter');
360
+ return;
361
+ }
362
+
363
+ const wrapped = wrapPartyConnection(connection);
364
+ this.#connections.set(connection.id, wrapped);
365
+ this.#connectionSessionIds.set(connection.id, sessionId);
366
+
367
+ const handlers = this.#core.getHandlers();
368
+ handlers.onWebSocketConnect(sessionId, wrapped, url);
369
+ }
370
+
371
+ /**
372
+ * Handle incoming WebSocket messages.
373
+ */
374
+ async onMessage(message: string, sender: PartyConnection): Promise<void> {
375
+ const sessionId = this.#connectionSessionIds.get(sender.id);
376
+ const wrapped = this.#connections.get(sender.id);
377
+
378
+ if (!sessionId || !wrapped) {
379
+ return;
380
+ }
381
+
382
+ // Dispatch to message handlers on the wrapper
383
+ wrapped._dispatchMessage(message);
384
+
385
+ // Notify the bridge core
386
+ const handlers = this.#core.getHandlers();
387
+ handlers.onWebSocketMessage(sessionId, wrapped, message);
388
+ }
389
+
390
+ /**
391
+ * Handle WebSocket connection close.
392
+ */
393
+ async onClose(connection: PartyConnection): Promise<void> {
394
+ const sessionId = this.#connectionSessionIds.get(connection.id);
395
+
396
+ if (sessionId) {
397
+ const handlers = this.#core.getHandlers();
398
+ handlers.onWebSocketClose(sessionId);
399
+ }
400
+
401
+ this.#connections.delete(connection.id);
402
+ this.#connectionSessionIds.delete(connection.id);
403
+ }
404
+
405
+ /**
406
+ * Handle WebSocket errors.
407
+ */
408
+ async onError(connection: PartyConnection, error: Error): Promise<void> {
409
+ const sessionId = this.#connectionSessionIds.get(connection.id);
410
+ console.error(`WebSocket error for session ${sessionId}:`, error);
411
+ }
412
+
413
+ /**
414
+ * Handle HTTP requests (MCP JSON-RPC, query endpoints).
415
+ */
416
+ async onRequest(request: Request): Promise<Response> {
417
+ const wrappedReq = wrapPartyRequest(request);
418
+ const handlers = this.#core.getHandlers();
419
+ const httpResponse = await handlers.onHttpRequest(wrappedReq);
420
+
421
+ // Check if this is an SSE response
422
+ if (isSSEResponse(httpResponse)) {
423
+ // Create a ReadableStream for SSE
424
+ const stream = new ReadableStream({
425
+ start(controller) {
426
+ // Create writer function that sends SSE-formatted data
427
+ const writer = (data: string): void => {
428
+ controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
429
+ };
430
+
431
+ // Set up the SSE stream
432
+ httpResponse.setup(writer, () => {
433
+ controller.close();
434
+ });
435
+
436
+ // Keep connection alive with periodic comments
437
+ const keepAlive = setInterval(() => {
438
+ try {
439
+ controller.enqueue(new TextEncoder().encode(': keepalive\n\n'));
440
+ } catch {
441
+ clearInterval(keepAlive);
442
+ }
443
+ }, 30000);
444
+ },
445
+ });
446
+
447
+ return new Response(stream, {
448
+ status: httpResponse.status,
449
+ headers: httpResponse.headers,
450
+ });
451
+ }
452
+
453
+ return new Response(httpResponse.body, {
454
+ status: httpResponse.status,
455
+ headers: httpResponse.headers,
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Handle PartyKit alarms for scheduled tasks.
461
+ * @see https://docs.partykit.io/guides/scheduling-tasks-with-alarms/
462
+ */
463
+ async onAlarm(): Promise<void> {
464
+ await this.#scheduler.handleAlarm();
465
+ }
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Pre-configured bridge class for direct export.
471
+ * Use `createPartyKitBridge()` if you need custom configuration.
472
+ *
473
+ * @example
474
+ * ```typescript
475
+ * // For simple cases where you configure via environment
476
+ * export { MCPWebBridgeParty } from '@mcp-web/bridge';
477
+ * ```
478
+ */
479
+ export const MCPWebBridgeParty = createPartyKitBridge({
480
+ name: 'MCP Web Bridge',
481
+ description: 'MCP Web bridge server on PartyKit',
482
+ });
@@ -0,0 +1,64 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { McpWebConfigSchema } from '@mcp-web/types';
3
+
4
+ test('Bridge config schema validates valid configuration', () => {
5
+ const result = McpWebConfigSchema.safeParse({
6
+ bridgeUrl: 'localhost:4001',
7
+ name: 'Test Bridge',
8
+ description: 'Test description',
9
+ });
10
+
11
+ expect(result.success).toBe(true);
12
+ if (result.success) {
13
+ expect(result.data.bridgeUrl === 'localhost:4001').toBe(true);
14
+ }
15
+ });
16
+
17
+ test('Bridge config schema strips protocol from bridgeUrl', () => {
18
+ const result = McpWebConfigSchema.safeParse({
19
+ bridgeUrl: 'ws://localhost:4001',
20
+ name: 'Test Bridge',
21
+ description: 'Test description',
22
+ });
23
+
24
+ expect(result.success).toBe(true);
25
+ if (result.success) {
26
+ // Protocol should be stripped by transform
27
+ expect(result.data.bridgeUrl === 'localhost:4001').toBe(true);
28
+ }
29
+ });
30
+
31
+ test('Bridge config schema uses default bridgeUrl', () => {
32
+ const result = McpWebConfigSchema.safeParse({
33
+ name: 'Test Bridge',
34
+ description: 'Test description',
35
+ });
36
+
37
+ expect(result.success).toBe(true);
38
+ if (result.success) {
39
+ expect(result.data.bridgeUrl === 'localhost:3001').toBe(true);
40
+ }
41
+ });
42
+
43
+ test('Bridge config schema accepts agentUrl and strips protocol', () => {
44
+ const result = McpWebConfigSchema.safeParse({
45
+ name: 'Test Bridge',
46
+ description: 'Test description',
47
+ bridgeUrl: 'localhost:4001',
48
+ agentUrl: 'http://localhost:3003',
49
+ });
50
+
51
+ expect(result.success).toBe(true);
52
+ if (result.success) {
53
+ // Protocol should be stripped by transform
54
+ expect(result.data.agentUrl === 'localhost:3003').toBe(true);
55
+ }
56
+ });
57
+
58
+ test('Bridge config schema requires name and description', () => {
59
+ const result = McpWebConfigSchema.safeParse({
60
+ bridgeUrl: 'localhost:4001',
61
+ });
62
+
63
+ expect(result.success).toBe(false);
64
+ });