@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
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
|
+
});
|