@mcp-web/core 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 (102) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +253 -0
  3. package/dist/addTool.typetest.d.ts +11 -0
  4. package/dist/addTool.typetest.d.ts.map +1 -0
  5. package/dist/addTool.typetest.js +248 -0
  6. package/dist/create-state-tools.d.ts +77 -0
  7. package/dist/create-state-tools.d.ts.map +1 -0
  8. package/dist/create-state-tools.js +181 -0
  9. package/dist/create-tool.d.ts +90 -0
  10. package/dist/create-tool.d.ts.map +1 -0
  11. package/dist/create-tool.js +82 -0
  12. package/dist/expanded-schema-tools/generate-fixed-shape-tools.d.ts +8 -0
  13. package/dist/expanded-schema-tools/generate-fixed-shape-tools.d.ts.map +1 -0
  14. package/dist/expanded-schema-tools/generate-fixed-shape-tools.js +53 -0
  15. package/dist/expanded-schema-tools/generate-fixed-shape-tools.test.d.ts +2 -0
  16. package/dist/expanded-schema-tools/generate-fixed-shape-tools.test.d.ts.map +1 -0
  17. package/dist/expanded-schema-tools/generate-fixed-shape-tools.test.js +331 -0
  18. package/dist/expanded-schema-tools/index.d.ts +4 -0
  19. package/dist/expanded-schema-tools/index.d.ts.map +1 -0
  20. package/dist/expanded-schema-tools/index.js +2 -0
  21. package/dist/expanded-schema-tools/integration.test.d.ts +2 -0
  22. package/dist/expanded-schema-tools/integration.test.d.ts.map +1 -0
  23. package/dist/expanded-schema-tools/integration.test.js +599 -0
  24. package/dist/expanded-schema-tools/schema-analysis.d.ts +18 -0
  25. package/dist/expanded-schema-tools/schema-analysis.d.ts.map +1 -0
  26. package/dist/expanded-schema-tools/schema-analysis.js +142 -0
  27. package/dist/expanded-schema-tools/schema-analysis.test.d.ts +2 -0
  28. package/dist/expanded-schema-tools/schema-analysis.test.d.ts.map +1 -0
  29. package/dist/expanded-schema-tools/schema-analysis.test.js +314 -0
  30. package/dist/expanded-schema-tools/schema-helpers.d.ts +69 -0
  31. package/dist/expanded-schema-tools/schema-helpers.d.ts.map +1 -0
  32. package/dist/expanded-schema-tools/schema-helpers.js +139 -0
  33. package/dist/expanded-schema-tools/schema-helpers.test.d.ts +2 -0
  34. package/dist/expanded-schema-tools/schema-helpers.test.d.ts.map +1 -0
  35. package/dist/expanded-schema-tools/schema-helpers.test.js +223 -0
  36. package/dist/expanded-schema-tools/tool-generator.d.ts +10 -0
  37. package/dist/expanded-schema-tools/tool-generator.d.ts.map +1 -0
  38. package/dist/expanded-schema-tools/tool-generator.js +430 -0
  39. package/dist/expanded-schema-tools/tool-generator.test.d.ts +2 -0
  40. package/dist/expanded-schema-tools/tool-generator.test.d.ts.map +1 -0
  41. package/dist/expanded-schema-tools/tool-generator.test.js +689 -0
  42. package/dist/expanded-schema-tools/types.d.ts +26 -0
  43. package/dist/expanded-schema-tools/types.d.ts.map +1 -0
  44. package/dist/expanded-schema-tools/types.js +1 -0
  45. package/dist/expanded-schema-tools/utils.d.ts +16 -0
  46. package/dist/expanded-schema-tools/utils.d.ts.map +1 -0
  47. package/dist/expanded-schema-tools/utils.js +35 -0
  48. package/dist/expanded-schema-tools/utils.test.d.ts +2 -0
  49. package/dist/expanded-schema-tools/utils.test.d.ts.map +1 -0
  50. package/dist/expanded-schema-tools/utils.test.js +169 -0
  51. package/dist/group-state.d.ts +60 -0
  52. package/dist/group-state.d.ts.map +1 -0
  53. package/dist/group-state.js +54 -0
  54. package/dist/index.d.ts +14 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +13 -0
  57. package/dist/query.d.ts +104 -0
  58. package/dist/query.d.ts.map +1 -0
  59. package/dist/query.js +128 -0
  60. package/dist/schema-helpers.d.ts +69 -0
  61. package/dist/schema-helpers.d.ts.map +1 -0
  62. package/dist/schema-helpers.js +139 -0
  63. package/dist/schemas.d.ts +140 -0
  64. package/dist/schemas.d.ts.map +1 -0
  65. package/dist/schemas.js +70 -0
  66. package/dist/tool-generators/generate-basic-state-tools.d.ts +23 -0
  67. package/dist/tool-generators/generate-basic-state-tools.d.ts.map +1 -0
  68. package/dist/tool-generators/generate-basic-state-tools.js +95 -0
  69. package/dist/tool-generators/generate-fixed-shape-tools.d.ts +8 -0
  70. package/dist/tool-generators/generate-fixed-shape-tools.d.ts.map +1 -0
  71. package/dist/tool-generators/generate-fixed-shape-tools.js +53 -0
  72. package/dist/tool-generators/index.d.ts +6 -0
  73. package/dist/tool-generators/index.d.ts.map +1 -0
  74. package/dist/tool-generators/index.js +3 -0
  75. package/dist/tool-generators/schema-analysis.d.ts +18 -0
  76. package/dist/tool-generators/schema-analysis.d.ts.map +1 -0
  77. package/dist/tool-generators/schema-analysis.js +142 -0
  78. package/dist/tool-generators/schema-helpers.d.ts +87 -0
  79. package/dist/tool-generators/schema-helpers.d.ts.map +1 -0
  80. package/dist/tool-generators/schema-helpers.js +157 -0
  81. package/dist/tool-generators/tool-generator.d.ts +11 -0
  82. package/dist/tool-generators/tool-generator.d.ts.map +1 -0
  83. package/dist/tool-generators/tool-generator.js +437 -0
  84. package/dist/tool-generators/types.d.ts +26 -0
  85. package/dist/tool-generators/types.d.ts.map +1 -0
  86. package/dist/tool-generators/types.js +1 -0
  87. package/dist/tool-generators/utils.d.ts +16 -0
  88. package/dist/tool-generators/utils.d.ts.map +1 -0
  89. package/dist/tool-generators/utils.js +35 -0
  90. package/dist/types.d.ts +17 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +1 -0
  93. package/dist/utils.d.ts +31 -0
  94. package/dist/utils.d.ts.map +1 -0
  95. package/dist/utils.js +108 -0
  96. package/dist/web.d.ts +680 -0
  97. package/dist/web.d.ts.map +1 -0
  98. package/dist/web.js +1312 -0
  99. package/dist/zod-to-tools.d.ts +49 -0
  100. package/dist/zod-to-tools.d.ts.map +1 -0
  101. package/dist/zod-to-tools.js +623 -0
  102. package/package.json +58 -0
package/dist/web.js ADDED
@@ -0,0 +1,1312 @@
1
+ import { decomposeSchema } from '@mcp-web/decompose-zod-schema';
2
+ import { AppDefinitionSchema, RESOURCE_MIME_TYPE, getDefaultAppResourceUri, getDefaultAppUrl, isCreatedApp, McpWebConfigSchema, QueryMessageSchema, ResourceDefinitionSchema, ToolDefinitionSchema, } from '@mcp-web/types';
3
+ import { ZodObject } from 'zod';
4
+ import { isCreatedStateTools } from './create-state-tools.js';
5
+ import { isCreatedTool } from './create-tool.js';
6
+ import { QueryResponse } from './query.js';
7
+ import { QueryRequestSchema, QueryResponseResultSchema } from './schemas.js';
8
+ import { generateBasicStateTools, generateToolsForSchema } from './tool-generators/index.js';
9
+ import { toJSONSchema, toToolMetadataJson } from './utils.js';
10
+ /**
11
+ * Main class for integrating web applications with AI agents via the Model Context Protocol (MCP).
12
+ *
13
+ * MCPWeb enables your web application to expose state and actions as tools that AI agents can
14
+ * interact with. It handles the WebSocket connection to the bridge server, tool registration,
15
+ * and bi-directional communication between your frontend and AI agents.
16
+ *
17
+ * @example Basic Usage
18
+ * ```typescript
19
+ * import { MCPWeb } from '@mcp-web/core';
20
+ *
21
+ * const mcp = new MCPWeb({
22
+ * name: 'My Todo App',
23
+ * description: 'A todo application that AI agents can control',
24
+ * autoConnect: true,
25
+ * });
26
+ *
27
+ * // Register a tool
28
+ * mcp.addTool({
29
+ * name: 'create_todo',
30
+ * description: 'Create a new todo item',
31
+ * handler: (input) => {
32
+ * const todo = { id: crypto.randomUUID(), ...input };
33
+ * todos.push(todo);
34
+ * return todo;
35
+ * },
36
+ * });
37
+ * ```
38
+ *
39
+ * @example With Full Configuration
40
+ * ```typescript
41
+ * const mcp = new MCPWeb({
42
+ * name: 'Checkers Game',
43
+ * description: 'Interactive checkers game controllable by AI agents',
44
+ * bridgeUrl: 'localhost:3001',
45
+ * icon: 'https://example.com/icon.png',
46
+ * agentUrl: 'localhost:3003',
47
+ * autoConnect: true,
48
+ * });
49
+ * ```
50
+ */
51
+ export class MCPWeb {
52
+ #ws = null;
53
+ #sessionId;
54
+ #authToken;
55
+ #tools = new Map();
56
+ #resources = new Map();
57
+ #apps = new Map();
58
+ #connected = false;
59
+ #isConnecting = false;
60
+ #authError = null;
61
+ #config;
62
+ #toolRegistrationErrorCallbacks = new Map();
63
+ #mcpConfig;
64
+ /**
65
+ * Creates a new MCPWeb instance with the specified configuration.
66
+ *
67
+ * The constructor initializes the WebSocket connection settings, generates or loads
68
+ * authentication credentials, and optionally auto-connects to the bridge server.
69
+ *
70
+ * @param config - Configuration object for MCPWeb
71
+ * @throws {Error} If configuration validation fails
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const mcp = new MCPWeb({
76
+ * name: 'My Todo App',
77
+ * description: 'A todo application that AI agents can control',
78
+ * bridgeUrl: 'localhost:3001',
79
+ * autoConnect: true,
80
+ * });
81
+ * ```
82
+ */
83
+ constructor(config) {
84
+ this.#config = McpWebConfigSchema.parse(config);
85
+ this.#sessionId = this.#generateSessionId();
86
+ this.#authToken = this.#resolveAuthToken(this.#config);
87
+ this.#mcpConfig = {
88
+ [this.#config.name]: {
89
+ command: 'npx',
90
+ args: ['@mcp-web/client'],
91
+ env: {
92
+ MCP_SERVER_URL: `${this.#getHttpProtocol()}//${this.#config.bridgeUrl}`,
93
+ AUTH_TOKEN: this.#authToken
94
+ }
95
+ }
96
+ };
97
+ if (this.#config.autoConnect) {
98
+ this.connect();
99
+ }
100
+ this.setupActivityTracking();
101
+ }
102
+ /**
103
+ * Unique session identifier for this frontend instance.
104
+ *
105
+ * The session ID is automatically generated on construction.
106
+ * It's used to identify this specific frontend instance in the bridge server.
107
+ *
108
+ * @returns The session ID string
109
+ */
110
+ get sessionId() {
111
+ return this.#sessionId;
112
+ }
113
+ /**
114
+ * Authentication token for this session.
115
+ *
116
+ * The auth token is either auto-generated, loaded from localStorage, or provided via config.
117
+ * By default, it's persisted in localStorage to maintain the same token across page reloads.
118
+ *
119
+ * @returns The authentication token string
120
+ */
121
+ get authToken() {
122
+ return this.#authToken;
123
+ }
124
+ /**
125
+ * Map of all registered tools.
126
+ *
127
+ * Provides access to the internal tool registry. Each tool is keyed by its name.
128
+ *
129
+ * @returns Map of tool names to processed tool definitions
130
+ */
131
+ get tools() {
132
+ return this.#tools;
133
+ }
134
+ /**
135
+ * Map of all registered resources.
136
+ *
137
+ * Provides access to the internal resource registry. Each resource is keyed by its URI.
138
+ *
139
+ * @returns Map of resource URIs to resource definitions
140
+ */
141
+ get resources() {
142
+ return this.#resources;
143
+ }
144
+ /**
145
+ * Map of all registered MCP Apps.
146
+ *
147
+ * Provides access to the internal app registry. Each app is keyed by its name.
148
+ *
149
+ * @returns Map of app names to processed app definitions
150
+ */
151
+ get apps() {
152
+ return this.#apps;
153
+ }
154
+ /**
155
+ * The processed MCPWeb configuration.
156
+ *
157
+ * Returns the validated and processed configuration with all defaults applied.
158
+ *
159
+ * @returns The complete configuration object
160
+ */
161
+ get config() {
162
+ return this.#config;
163
+ }
164
+ /**
165
+ * Configuration object for the AI host app (e.g., Claude Desktop) using stdio transport.
166
+ *
167
+ * Use this to configure the MCP client in your AI host application.
168
+ * It contains the connection details and authentication credentials needed
169
+ * for the AI agent to connect to the bridge server via the `@mcp-web/client` stdio wrapper.
170
+ *
171
+ * For a simpler configuration, consider using `remoteMcpConfig` instead, which
172
+ * uses Remote MCP (Streamable HTTP) and doesn't require an intermediate process.
173
+ *
174
+ * @returns MCP client configuration object for stdio transport
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * console.log('Add this to your Claude Desktop config:');
179
+ * console.log(JSON.stringify(mcp.mcpConfig, null, 2));
180
+ * ```
181
+ */
182
+ get mcpConfig() {
183
+ return this.#mcpConfig;
184
+ }
185
+ /**
186
+ * Configuration object for the AI host app (e.g., Claude Desktop) using Remote MCP.
187
+ *
188
+ * This is the recommended configuration method. It uses Remote MCP (Streamable HTTP)
189
+ * to connect directly to the bridge server via URL, without needing an intermediate
190
+ * stdio process like `@mcp-web/client`.
191
+ *
192
+ * @returns MCP client configuration object for Remote MCP (URL-based)
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * console.log('Add this to your Claude Desktop config:');
197
+ * console.log(JSON.stringify(mcp.remoteMcpConfig, null, 2));
198
+ * // Output:
199
+ * // {
200
+ * // "my-app": {
201
+ * // "url": "https://localhost:3001?token=your-auth-token"
202
+ * // }
203
+ * // }
204
+ * ```
205
+ */
206
+ get remoteMcpConfig() {
207
+ return {
208
+ [this.#config.name]: {
209
+ url: `${this.#getHttpProtocol()}//${this.#config.bridgeUrl}?token=${this.#authToken}`,
210
+ },
211
+ };
212
+ }
213
+ #getBridgeWsUrl() {
214
+ return `${this.#getWsProtocol()}//${this.#config.bridgeUrl}`;
215
+ }
216
+ #getWsProtocol() {
217
+ return globalThis.window?.location?.protocol === 'https:' ? 'wss:' : 'ws:';
218
+ }
219
+ #getHttpProtocol() {
220
+ return globalThis.window?.location?.protocol === 'https:' ? 'https:' : 'http:';
221
+ }
222
+ #generateSessionId() {
223
+ return globalThis.crypto.randomUUID();
224
+ }
225
+ #generateAuthToken() {
226
+ return globalThis.crypto.randomUUID();
227
+ }
228
+ #loadAuthToken() {
229
+ try {
230
+ return globalThis.localStorage?.getItem('mcp-web-auth-token');
231
+ }
232
+ catch (error) {
233
+ console.warn('Failed to load auth token from localStorage:', error);
234
+ return null;
235
+ }
236
+ }
237
+ #saveAuthToken(token) {
238
+ try {
239
+ globalThis.localStorage?.setItem('mcp-web-auth-token', token);
240
+ }
241
+ catch (error) {
242
+ console.warn('Failed to save auth token to localStorage:', error);
243
+ }
244
+ }
245
+ #resolveAuthToken(config) {
246
+ // Use provided auth token if available
247
+ if (config.authToken) {
248
+ // Save it to localStorage if persistence is enabled
249
+ if (globalThis.localStorage && config.persistAuthToken !== false) {
250
+ this.#saveAuthToken(config.authToken);
251
+ }
252
+ return config.authToken;
253
+ }
254
+ // Try to load from localStorage if persistence is enabled
255
+ if (globalThis.localStorage && config.persistAuthToken !== false) {
256
+ const savedToken = this.#loadAuthToken();
257
+ if (savedToken) {
258
+ return savedToken;
259
+ }
260
+ }
261
+ // Generate new token
262
+ const newToken = this.#generateAuthToken();
263
+ // Save it if persistence is enabled
264
+ if (config.persistAuthToken !== false) {
265
+ this.#saveAuthToken(newToken);
266
+ }
267
+ return newToken;
268
+ }
269
+ /**
270
+ * Establishes connection to the bridge server.
271
+ *
272
+ * Opens a WebSocket connection to the bridge server and authenticates using the session's
273
+ * auth token. If `autoConnect` is enabled in the config, this is called automatically
274
+ * during construction.
275
+ *
276
+ * This method is idempotent - calling it multiple times while already connected or
277
+ * connecting will return the same promise.
278
+ *
279
+ * @returns Promise that resolves to `true` when authenticated and ready
280
+ * @throws {Error} If WebSocket connection fails
281
+ *
282
+ * @example Manual Connection
283
+ * ```typescript
284
+ * const mcp = new MCPWeb({
285
+ * name: 'My App',
286
+ * description: 'My application',
287
+ * autoConnect: false, // Disable auto-connect
288
+ * });
289
+ *
290
+ * // Connect when ready
291
+ * await mcp.connect();
292
+ * console.log('Connected to bridge');
293
+ * ```
294
+ *
295
+ * @example Check Connection Status
296
+ * ```typescript
297
+ * if (!mcp.connected) {
298
+ * await mcp.connect();
299
+ * }
300
+ * ```
301
+ */
302
+ async connect() {
303
+ // Prevent duplicate connections
304
+ if (this.#connected) {
305
+ return Promise.resolve(true);
306
+ }
307
+ if (this.#isConnecting) {
308
+ // Return a promise that resolves when the current connection completes
309
+ return new Promise((resolve) => {
310
+ const checkConnection = () => {
311
+ if (this.#connected) {
312
+ resolve(true);
313
+ }
314
+ else if (!this.#isConnecting) {
315
+ // Connection failed, try again
316
+ this.connect().then(resolve);
317
+ }
318
+ else {
319
+ setTimeout(checkConnection, 100);
320
+ }
321
+ };
322
+ checkConnection();
323
+ });
324
+ }
325
+ this.#isConnecting = true;
326
+ this.#authError = null;
327
+ return new Promise((resolve, reject) => {
328
+ try {
329
+ const wsUrl = `${this.#getBridgeWsUrl()}?session=${this.sessionId}`;
330
+ this.#ws = new WebSocket(wsUrl);
331
+ this.#ws.onopen = () => {
332
+ // Use setTimeout to ensure WebSocket is fully ready
333
+ setTimeout(() => this.authenticate(), 0);
334
+ };
335
+ this.#ws.onmessage = (event) => {
336
+ try {
337
+ const message = JSON.parse(event.data);
338
+ this.handleMessage(message);
339
+ }
340
+ catch (error) {
341
+ console.error('Failed to parse message:', error);
342
+ }
343
+ };
344
+ this.#ws.onclose = () => {
345
+ this.#connected = false;
346
+ this.#isConnecting = false;
347
+ // Don't reconnect if authentication was rejected
348
+ if (!this.#authError) {
349
+ this.scheduleReconnect();
350
+ }
351
+ };
352
+ this.#ws.onerror = (error) => {
353
+ console.error('WebSocket error:', error);
354
+ this.#isConnecting = false;
355
+ reject(error);
356
+ };
357
+ // Resolve when authenticated, reject on auth failure
358
+ const checkConnection = () => {
359
+ if (this.#connected) {
360
+ this.#isConnecting = false;
361
+ resolve(true);
362
+ }
363
+ else if (this.#authError) {
364
+ this.#isConnecting = false;
365
+ reject(new Error(this.#authError.error));
366
+ }
367
+ else {
368
+ setTimeout(checkConnection, 100);
369
+ }
370
+ };
371
+ setTimeout(checkConnection, 100);
372
+ }
373
+ catch (error) {
374
+ this.#isConnecting = false;
375
+ reject(error);
376
+ }
377
+ });
378
+ }
379
+ authenticate() {
380
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN)
381
+ return;
382
+ const authMessage = {
383
+ type: 'authenticate',
384
+ sessionId: this.sessionId,
385
+ authToken: this.#authToken,
386
+ origin: globalThis.window?.location?.origin,
387
+ pageTitle: globalThis.document?.title,
388
+ sessionName: this.#config.sessionName,
389
+ userAgent: globalThis.navigator?.userAgent,
390
+ timestamp: Date.now()
391
+ };
392
+ this.#ws.send(JSON.stringify(authMessage));
393
+ }
394
+ handleMessage(message) {
395
+ switch (message.type) {
396
+ case 'authenticated':
397
+ this.#connected = true;
398
+ this.registerAllTools();
399
+ this.registerAllResources();
400
+ break;
401
+ case 'authentication-failed': {
402
+ const failedMessage = message;
403
+ this.#authError = {
404
+ error: failedMessage.error,
405
+ code: failedMessage.code,
406
+ };
407
+ break;
408
+ }
409
+ case 'tool-call':
410
+ this.handleToolCall(message);
411
+ break;
412
+ case 'resource-read':
413
+ this.handleResourceRead(message);
414
+ break;
415
+ case 'tool-registration-error':
416
+ this.handleToolRegistrationError(message);
417
+ break;
418
+ case 'query_accepted':
419
+ case 'query_progress':
420
+ case 'query_failure':
421
+ case 'query_complete':
422
+ case 'query_cancel':
423
+ // Query responses are handled by query-specific listeners in query() method
424
+ // (see line ~498 where addEventListener('message') is set up per query)
425
+ break;
426
+ default:
427
+ console.warn('Unknown message type:', message.type);
428
+ }
429
+ }
430
+ async handleToolCall(message) {
431
+ const { requestId, toolName, toolInput } = message;
432
+ try {
433
+ const tool = this.tools.get(toolName);
434
+ if (!tool) {
435
+ this.sendToolResponse(requestId, { error: `Tool '${toolName}' not found` });
436
+ return;
437
+ }
438
+ if (tool.inputZodSchema) {
439
+ const parsedToolInput = tool.inputZodSchema.safeParse(toolInput);
440
+ if (!parsedToolInput.success) {
441
+ this.sendToolResponse(requestId, { error: `Invalid input: ${parsedToolInput.error.message}` });
442
+ return;
443
+ }
444
+ }
445
+ // Execute the tool
446
+ const result = await tool.handler(toolInput);
447
+ // Validate output if Zod schema is provided
448
+ if (tool.outputZodSchema) {
449
+ const parsedResult = tool.outputZodSchema.safeParse(result);
450
+ if (!parsedResult.success) {
451
+ this.sendToolResponse(requestId, { error: `Invalid output: ${parsedResult.error.message}` });
452
+ return;
453
+ }
454
+ }
455
+ // Send the raw result - the bridge will handle formatting
456
+ this.sendToolResponse(requestId, result);
457
+ }
458
+ catch (error) {
459
+ // Only log in non-test environments
460
+ if (globalThis.process?.env?.NODE_ENV !== 'test') {
461
+ console.debug(`Tool execution error for ${toolName}:`, error);
462
+ }
463
+ this.sendToolResponse(requestId, {
464
+ error: `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
465
+ });
466
+ }
467
+ }
468
+ sendToolResponse(requestId, result) {
469
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
470
+ console.error('Cannot send tool response: WebSocket not connected');
471
+ return;
472
+ }
473
+ const response = {
474
+ type: 'tool-response',
475
+ requestId,
476
+ result
477
+ };
478
+ this.#ws.send(JSON.stringify(response));
479
+ }
480
+ registerAllTools() {
481
+ this.tools.forEach((tool) => {
482
+ this.registerTool(tool);
483
+ });
484
+ }
485
+ registerTool(tool) {
486
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
487
+ return;
488
+ }
489
+ const message = {
490
+ type: 'register-tool',
491
+ tool: {
492
+ name: tool.name,
493
+ description: tool.description,
494
+ inputSchema: tool.inputJsonSchema,
495
+ outputSchema: tool.outputJsonSchema,
496
+ // Forward _meta (e.g., _meta.ui.resourceUri for MCP Apps)
497
+ ...(tool._meta ? { _meta: tool._meta } : {}),
498
+ }
499
+ };
500
+ this.#ws.send(JSON.stringify(message));
501
+ }
502
+ registerAllResources() {
503
+ this.resources.forEach((resource) => {
504
+ this.registerResource(resource);
505
+ });
506
+ }
507
+ registerResource(resource) {
508
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
509
+ return;
510
+ }
511
+ const message = {
512
+ type: 'register-resource',
513
+ resource: {
514
+ uri: resource.uri,
515
+ name: resource.name,
516
+ description: resource.description,
517
+ mimeType: resource.mimeType ?? 'text/html',
518
+ }
519
+ };
520
+ this.#ws.send(JSON.stringify(message));
521
+ }
522
+ async handleResourceRead(message) {
523
+ const { requestId, uri } = message;
524
+ try {
525
+ const resource = this.resources.get(uri);
526
+ if (!resource) {
527
+ this.sendResourceResponse(requestId, {
528
+ error: `Resource '${uri}' not found`,
529
+ mimeType: 'text/plain',
530
+ });
531
+ return;
532
+ }
533
+ const content = await resource.handler();
534
+ const mimeType = resource.mimeType ?? 'text/html';
535
+ if (content instanceof Uint8Array) {
536
+ const base64 = btoa(String.fromCharCode(...content));
537
+ this.sendResourceResponse(requestId, { blob: base64, mimeType });
538
+ }
539
+ else {
540
+ this.sendResourceResponse(requestId, { content, mimeType });
541
+ }
542
+ }
543
+ catch (error) {
544
+ if (globalThis.process?.env?.NODE_ENV !== 'test') {
545
+ console.debug(`Resource read error for ${uri}:`, error);
546
+ }
547
+ this.sendResourceResponse(requestId, {
548
+ error: `Resource read failed: ${error instanceof Error ? error.message : String(error)}`,
549
+ mimeType: 'text/plain',
550
+ });
551
+ }
552
+ }
553
+ sendResourceResponse(requestId, result) {
554
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
555
+ console.error('Cannot send resource response: WebSocket not connected');
556
+ return;
557
+ }
558
+ const response = {
559
+ type: 'resource-response',
560
+ requestId,
561
+ ...result,
562
+ };
563
+ this.#ws.send(JSON.stringify(response));
564
+ }
565
+ handleToolRegistrationError(message) {
566
+ const { toolName, error, message: errorMessage } = message;
567
+ // Remove the tool since the bridge rejected it
568
+ this.tools.delete(toolName);
569
+ // Call the per-tool callback if registered
570
+ const callback = this.#toolRegistrationErrorCallbacks.get(toolName);
571
+ if (callback) {
572
+ this.#toolRegistrationErrorCallbacks.delete(toolName);
573
+ callback({ toolName, code: error, message: errorMessage });
574
+ }
575
+ else {
576
+ // No callback registered — log a warning so the error isn't silently lost
577
+ console.warn(`Tool registration rejected by bridge: '${toolName}' — ${errorMessage}`);
578
+ }
579
+ }
580
+ scheduleReconnect() {
581
+ if (!this.#ws)
582
+ return;
583
+ setTimeout(() => {
584
+ if (!this.#connected && this.#ws) {
585
+ this.connect().catch(console.error);
586
+ }
587
+ }, 5000);
588
+ }
589
+ setupActivityTracking() {
590
+ const updateActivity = () => {
591
+ if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
592
+ this.#ws.send(JSON.stringify({
593
+ type: 'activity',
594
+ timestamp: Date.now()
595
+ }));
596
+ }
597
+ };
598
+ // Track user activity (only in browser environment)
599
+ if (globalThis.document) {
600
+ ['click', 'scroll', 'keydown', 'mousemove'].forEach(event => {
601
+ globalThis.document.addEventListener(event, updateActivity, { passive: true });
602
+ });
603
+ }
604
+ // Periodic activity update
605
+ setInterval(updateActivity, 30000); // Every 30 seconds
606
+ }
607
+ addTool(tool, options) {
608
+ // Handle CreatedTool
609
+ if (isCreatedTool(tool)) {
610
+ return this.addTool(tool.definition, options);
611
+ }
612
+ const validationResult = ToolDefinitionSchema.safeParse(tool);
613
+ if (!validationResult.success) {
614
+ throw new Error(`Invalid tool definition: ${validationResult.error.message}`);
615
+ }
616
+ const isInputZodSchema = validationResult.data.inputSchema && 'safeParse' in validationResult.data.inputSchema;
617
+ const isOutputZodSchema = validationResult.data.outputSchema && 'safeParse' in validationResult.data.outputSchema;
618
+ // Create processed tool with both Zod and JSON schemas
619
+ const processedTool = {
620
+ ...validationResult.data,
621
+ // Preserve _meta from original input (not in Zod schema)
622
+ _meta: tool._meta,
623
+ inputZodSchema: isInputZodSchema ? validationResult.data.inputSchema : undefined,
624
+ outputZodSchema: isOutputZodSchema ? validationResult.data.outputSchema : undefined,
625
+ inputJsonSchema: validationResult.data.inputSchema ? toJSONSchema(validationResult.data.inputSchema) : undefined,
626
+ outputJsonSchema: validationResult.data.outputSchema ? toJSONSchema(validationResult.data.outputSchema) : undefined
627
+ };
628
+ // Wrap handler with input validation if Zod schema is provided
629
+ if (processedTool.inputZodSchema) {
630
+ const originalHandler = processedTool.handler;
631
+ processedTool.handler = async (input) => {
632
+ const validationResult = processedTool.inputZodSchema?.safeParse(input);
633
+ if (!validationResult?.success) {
634
+ throw new Error(`Invalid tool input: ${validationResult?.error?.message}`);
635
+ }
636
+ return originalHandler(validationResult?.data);
637
+ };
638
+ }
639
+ this.tools.set(processedTool.name, processedTool);
640
+ // Store registration error callback if provided
641
+ if (options?.onRegistrationError) {
642
+ this.#toolRegistrationErrorCallbacks.set(processedTool.name, options.onRegistrationError);
643
+ }
644
+ // Register immediately if connected
645
+ if (this.#connected) {
646
+ this.registerTool(processedTool);
647
+ }
648
+ return tool;
649
+ }
650
+ /**
651
+ * Removes a registered tool.
652
+ *
653
+ * After removal, AI agents will no longer be able to call this tool.
654
+ * Useful for dynamically disabling features or cleaning up when tools are no longer needed.
655
+ *
656
+ * @param name - Name of the tool to remove
657
+ *
658
+ * @example
659
+ * ```typescript
660
+ * // Remove a specific tool
661
+ * mcp.removeTool('create_todo');
662
+ * ```
663
+ */
664
+ removeTool(name) {
665
+ this.tools.delete(name);
666
+ this.#toolRegistrationErrorCallbacks.delete(name);
667
+ }
668
+ /**
669
+ * Registers a resource that AI agents can read.
670
+ *
671
+ * Resources are content that AI agents can request, such as HTML for MCP Apps.
672
+ * The handler function is called when the AI requests the resource content.
673
+ *
674
+ * @param resource - Resource configuration including URI, name, description, and handler
675
+ * @returns The registered resource definition
676
+ * @throws {Error} If resource definition is invalid
677
+ *
678
+ * @example Basic Resource
679
+ * ```typescript
680
+ * mcp.addResource({
681
+ * uri: 'ui://my-app/statistics.html',
682
+ * name: 'Statistics View',
683
+ * description: 'Statistics visualization for the app',
684
+ * mimeType: 'text/html',
685
+ * handler: async () => {
686
+ * const response = await fetch('/mcp-web-apps/statistics.html');
687
+ * return response.text();
688
+ * },
689
+ * });
690
+ * ```
691
+ *
692
+ * @example Resource from URL
693
+ * ```typescript
694
+ * mcp.addResource({
695
+ * uri: 'ui://my-app/chart.html',
696
+ * name: 'Chart Component',
697
+ * handler: () => fetch('/components/chart.html').then(r => r.text()),
698
+ * });
699
+ * ```
700
+ */
701
+ addResource(resource) {
702
+ const validationResult = ResourceDefinitionSchema.safeParse(resource);
703
+ if (!validationResult.success) {
704
+ throw new Error(`Invalid resource definition: ${validationResult.error.message}`);
705
+ }
706
+ this.#resources.set(resource.uri, resource);
707
+ // Register immediately if connected
708
+ if (this.#connected) {
709
+ this.registerResource(resource);
710
+ }
711
+ return resource;
712
+ }
713
+ /**
714
+ * Removes a registered resource.
715
+ *
716
+ * After removal, AI agents will no longer be able to read this resource.
717
+ *
718
+ * @param uri - URI of the resource to remove
719
+ *
720
+ * @example
721
+ * ```typescript
722
+ * mcp.removeResource('ui://my-app/statistics.html');
723
+ * ```
724
+ */
725
+ removeResource(uri) {
726
+ this.#resources.delete(uri);
727
+ }
728
+ /**
729
+ * Registers an MCP App that AI agents can invoke to show visual UI.
730
+ *
731
+ * An MCP App combines a tool (that AI calls to get props) with a resource (the HTML UI).
732
+ * When AI calls the tool, the handler returns props. The tool response includes
733
+ * `_meta.ui.resourceUri` which tells the host to fetch and render the app HTML.
734
+ *
735
+ * @param app - App configuration including name, description, handler, and optional URL
736
+ * @returns The registered app definition
737
+ * @throws {Error} If app definition is invalid
738
+ *
739
+ * @example Basic App
740
+ * ```typescript
741
+ * mcp.addApp({
742
+ * name: 'show_statistics',
743
+ * description: 'Display statistics visualization',
744
+ * handler: () => ({
745
+ * completionRate: 0.75,
746
+ * totalTasks: 100,
747
+ * completedTasks: 75,
748
+ * }),
749
+ * });
750
+ * ```
751
+ *
752
+ * @example With Input Schema
753
+ * ```typescript
754
+ * mcp.addApp({
755
+ * name: 'show_chart',
756
+ * description: 'Display a chart with the given data',
757
+ * inputSchema: z.object({
758
+ * chartType: z.enum(['bar', 'line', 'pie']).describe('Type of chart'),
759
+ * title: z.string().describe('Chart title'),
760
+ * }),
761
+ * handler: ({ chartType, title }) => ({
762
+ * chartType,
763
+ * title,
764
+ * data: getChartData(),
765
+ * }),
766
+ * });
767
+ * ```
768
+ *
769
+ * @example With Custom URL
770
+ * ```typescript
771
+ * mcp.addApp({
772
+ * name: 'show_dashboard',
773
+ * description: 'Display the main dashboard',
774
+ * handler: () => getDashboardData(),
775
+ * url: '/custom/path/dashboard.html',
776
+ * });
777
+ * ```
778
+ *
779
+ * @example With Pre-Created App
780
+ * ```typescript
781
+ * import { createApp } from '@mcp-web/app';
782
+ *
783
+ * const statisticsApp = createApp({
784
+ * name: 'show_statistics',
785
+ * description: 'Display statistics',
786
+ * handler: () => ({ rate: 0.75 }),
787
+ * });
788
+ *
789
+ * mcp.addApp(statisticsApp);
790
+ * ```
791
+ */
792
+ addApp(app) {
793
+ // Handle CreatedApp
794
+ if (isCreatedApp(app)) {
795
+ return this.addApp(app.definition);
796
+ }
797
+ const validationResult = AppDefinitionSchema.safeParse(app);
798
+ if (!validationResult.success) {
799
+ throw new Error(`Invalid app definition: ${validationResult.error.message}`);
800
+ }
801
+ // Process the app with resolved defaults
802
+ const resolvedUrl = app.url ?? getDefaultAppUrl(app.name);
803
+ const resolvedResourceUri = app.resourceUri ?? getDefaultAppResourceUri(app.name);
804
+ const processedApp = {
805
+ ...app,
806
+ resolvedUrl,
807
+ resolvedResourceUri,
808
+ };
809
+ this.#apps.set(app.name, processedApp);
810
+ // Create and register the tool that wraps the handler
811
+ const toolHandler = async (input) => {
812
+ // Call the app's handler to get props
813
+ const props = await processedApp.handler(input);
814
+ // Return props with _meta.ui for MCP Apps protocol
815
+ return {
816
+ ...props,
817
+ _meta: {
818
+ ui: {
819
+ resourceUri: resolvedResourceUri,
820
+ },
821
+ },
822
+ };
823
+ };
824
+ // Use the JSON Schema overload - schemas will be converted internally
825
+ this.addTool({
826
+ name: app.name,
827
+ description: app.description,
828
+ handler: toolHandler,
829
+ inputSchema: app.inputSchema
830
+ ? toJSONSchema(app.inputSchema)
831
+ : undefined,
832
+ outputSchema: app.propsSchema
833
+ ? toJSONSchema(app.propsSchema)
834
+ : undefined,
835
+ // Include _meta.ui in tool description (per ext-apps spec)
836
+ // so the host knows this tool has a UI before calling it
837
+ _meta: {
838
+ ui: {
839
+ resourceUri: resolvedResourceUri,
840
+ },
841
+ },
842
+ });
843
+ // Create and register the resource that serves the app HTML
844
+ this.addResource({
845
+ uri: resolvedResourceUri,
846
+ name: `${app.name} UI`,
847
+ description: `HTML UI for ${app.name}`,
848
+ mimeType: RESOURCE_MIME_TYPE,
849
+ handler: async () => {
850
+ // Fetch the HTML from the URL
851
+ const response = await fetch(resolvedUrl);
852
+ if (!response.ok) {
853
+ throw new Error(`Failed to fetch app HTML from ${resolvedUrl}: ${response.status}`);
854
+ }
855
+ return response.text();
856
+ },
857
+ });
858
+ return app;
859
+ }
860
+ /**
861
+ * Removes a registered MCP App.
862
+ *
863
+ * After removal, AI agents will no longer be able to invoke this app.
864
+ * This also removes the associated tool and resource.
865
+ *
866
+ * @param name - Name of the app to remove
867
+ *
868
+ * @example
869
+ * ```typescript
870
+ * mcp.removeApp('show_statistics');
871
+ * ```
872
+ */
873
+ removeApp(name) {
874
+ const app = this.#apps.get(name);
875
+ if (app) {
876
+ // Remove the tool
877
+ this.removeTool(name);
878
+ // Remove the resource
879
+ this.removeResource(app.resolvedResourceUri);
880
+ // Remove from apps map
881
+ this.#apps.delete(name);
882
+ }
883
+ }
884
+ // Implementation
885
+ addStateTools(optionsOrCreated) {
886
+ // Handle CreatedStateTools
887
+ if (isCreatedStateTools(optionsOrCreated)) {
888
+ const created = optionsOrCreated;
889
+ const registeredTools = [];
890
+ // Register all tools
891
+ for (const tool of created.tools) {
892
+ registeredTools.push(this.addTool(tool));
893
+ }
894
+ // Build cleanup function
895
+ const cleanup = () => {
896
+ for (const tool of registeredTools) {
897
+ this.removeTool(tool.name);
898
+ }
899
+ };
900
+ // Return tuple based on isExpanded
901
+ if (created.isExpanded) {
902
+ return [registeredTools[0], registeredTools.slice(1), cleanup];
903
+ }
904
+ return [registeredTools[0], registeredTools[1], cleanup];
905
+ }
906
+ // At this point, optionsOrCreated is guaranteed to be the config object (not CreatedStateTools)
907
+ const options = optionsOrCreated;
908
+ const { name, description, get, set, schema, schemaSplit, expand } = options;
909
+ const allTools = [];
910
+ // Determine if we're expanding (schemaSplit or expand flag)
911
+ const isZodObjectSchema = schema instanceof ZodObject;
912
+ const shouldDecompose = schemaSplit && isZodObjectSchema;
913
+ // Step 1: Apply schemaSplit if provided
914
+ let decomposedSchemas = [];
915
+ if (shouldDecompose) {
916
+ decomposedSchemas = decomposeSchema(schema, schemaSplit);
917
+ }
918
+ // Step 2: Generate tools based on mode
919
+ if (expand) {
920
+ // Expanded mode: generate targeted tools for collections
921
+ if (decomposedSchemas.length > 0) {
922
+ // Generate expanded tools for each decomposed part
923
+ for (const decomposed of decomposedSchemas) {
924
+ const result = generateToolsForSchema({
925
+ name: `${name}_${decomposed.name}`,
926
+ description: `${decomposed.name} in ${description}`,
927
+ get: () => {
928
+ const fullState = get();
929
+ const extracted = {};
930
+ for (const path of decomposed.targetPaths) {
931
+ extracted[path] = fullState[path];
932
+ }
933
+ return Object.keys(extracted).length === 1
934
+ ? extracted[decomposed.targetPaths[0]]
935
+ : extracted;
936
+ },
937
+ set: (value) => {
938
+ const current = get();
939
+ if (decomposed.targetPaths.length === 1) {
940
+ set({ ...current, [decomposed.targetPaths[0]]: value });
941
+ }
942
+ else {
943
+ set({ ...current, ...value });
944
+ }
945
+ },
946
+ schema: decomposed.schema,
947
+ });
948
+ // Register and collect tools
949
+ for (const tool of result.tools) {
950
+ allTools.push(this.addTool(tool));
951
+ }
952
+ // Log warnings if any
953
+ for (const warning of result.warnings) {
954
+ console.warn(warning);
955
+ }
956
+ }
957
+ }
958
+ else {
959
+ // Generate expanded tools for full schema
960
+ const result = generateToolsForSchema({
961
+ name,
962
+ description,
963
+ get: get,
964
+ set: set,
965
+ schema: schema,
966
+ });
967
+ // Register and collect tools
968
+ for (const tool of result.tools) {
969
+ allTools.push(this.addTool(tool));
970
+ }
971
+ // Log warnings if any
972
+ for (const warning of result.warnings) {
973
+ console.warn(warning);
974
+ }
975
+ }
976
+ }
977
+ else if (shouldDecompose) {
978
+ // Decompose mode without expand: use basic state tools with decomposition
979
+ const basicResult = generateBasicStateTools({
980
+ name,
981
+ description,
982
+ get,
983
+ set,
984
+ schema: schema,
985
+ schemaSplit,
986
+ });
987
+ // Register getter
988
+ allTools.push(this.addTool(basicResult.getter));
989
+ // Register all setters
990
+ for (const setter of basicResult.setters) {
991
+ allTools.push(this.addTool(setter));
992
+ }
993
+ }
994
+ else {
995
+ // Basic mode: simple get/set tools
996
+ const basicResult = generateBasicStateTools({
997
+ name,
998
+ description,
999
+ get,
1000
+ set,
1001
+ schema: schema,
1002
+ });
1003
+ // Register getter
1004
+ allTools.push(this.addTool(basicResult.getter));
1005
+ // Register setter (should be single)
1006
+ for (const setter of basicResult.setters) {
1007
+ allTools.push(this.addTool(setter));
1008
+ }
1009
+ }
1010
+ // Build cleanup function
1011
+ const cleanup = () => {
1012
+ for (const tool of allTools) {
1013
+ this.removeTool(tool.name);
1014
+ }
1015
+ };
1016
+ // Return tuple: [getter, setter(s), cleanup]
1017
+ const getter = allTools[0];
1018
+ const settersOrExpanded = allTools.slice(1);
1019
+ // Return single setter if basic mode (no schemaSplit, no expand)
1020
+ if (!expand && !shouldDecompose) {
1021
+ return [getter, settersOrExpanded[0], cleanup];
1022
+ }
1023
+ return [getter, settersOrExpanded, cleanup];
1024
+ }
1025
+ /**
1026
+ * Whether the client is currently connected to the bridge server.
1027
+ *
1028
+ * @returns `true` if connected, `false` otherwise
1029
+ *
1030
+ * @example
1031
+ * ```typescript
1032
+ * if (mcp.connected) {
1033
+ * console.log('Ready to receive tool calls');
1034
+ * } else {
1035
+ * await mcp.connect();
1036
+ * }
1037
+ * ```
1038
+ */
1039
+ get connected() {
1040
+ return this.#connected;
1041
+ }
1042
+ /**
1043
+ * Disconnects from the bridge server.
1044
+ *
1045
+ * Closes the WebSocket connection and cleans up event handlers.
1046
+ * Useful for cleanup when unmounting components or closing the application.
1047
+ *
1048
+ * @example
1049
+ * ```typescript
1050
+ * // In a Vue component lifecycle hook
1051
+ * onUnmounted(() => {
1052
+ * mcp.disconnect();
1053
+ * });
1054
+ * ```
1055
+ */
1056
+ disconnect() {
1057
+ if (this.#ws) {
1058
+ this.#ws.onmessage = null;
1059
+ this.#ws.onopen = null;
1060
+ this.#ws.onerror = null;
1061
+ this.#ws.onclose = null;
1062
+ this.#ws.close();
1063
+ this.#ws = null;
1064
+ }
1065
+ this.#connected = false;
1066
+ }
1067
+ /**
1068
+ * Gets list of all registered tool names.
1069
+ *
1070
+ * @returns Array of tool names
1071
+ *
1072
+ * @example
1073
+ * ```typescript
1074
+ * const toolNames = mcp.getTools();
1075
+ * console.log('Available tools:', toolNames);
1076
+ * ```
1077
+ */
1078
+ getTools() {
1079
+ return Array.from(this.tools.keys());
1080
+ }
1081
+ /**
1082
+ * Triggers an AI agent query from your frontend code.
1083
+ *
1084
+ * Requires `agentUrl` to be configured in MCPWeb config. Sends a query to the AI agent
1085
+ * and returns a QueryResponse object that can be iterated to stream events.
1086
+ *
1087
+ * @param request - Query request with prompt and optional context
1088
+ * @param signal - Optional AbortSignal for canceling the query
1089
+ * @returns QueryResponse object that streams events
1090
+ * @throws {Error} If `agentUrl` is not configured or not connected to bridge
1091
+ *
1092
+ * @example Basic Query
1093
+ * ```typescript
1094
+ * const query = mcp.query({
1095
+ * prompt: 'Analyze the current todos and suggest priorities',
1096
+ * });
1097
+ *
1098
+ * for await (const event of query) {
1099
+ * if (event.type === 'query_complete') {
1100
+ * console.log('Result:', event.result);
1101
+ * }
1102
+ * }
1103
+ * ```
1104
+ *
1105
+ * @example With Context
1106
+ * ```typescript
1107
+ * const query = mcp.query({
1108
+ * prompt: 'Update the todo with highest priority',
1109
+ * context: [todosTool], // Provide specific tools as context
1110
+ * });
1111
+ *
1112
+ * for await (const event of query) {
1113
+ * console.log('Event:', event);
1114
+ * }
1115
+ * ```
1116
+ *
1117
+ * @example With Cancellation
1118
+ * ```typescript
1119
+ * const abortController = new AbortController();
1120
+ * const query = mcp.query(
1121
+ * { prompt: 'Long running task' },
1122
+ * abortController.signal
1123
+ * );
1124
+ *
1125
+ * // Cancel after 5 seconds
1126
+ * setTimeout(() => abortController.abort(), 5000);
1127
+ * ```
1128
+ */
1129
+ query(request, signal) {
1130
+ if (!this.#config.agentUrl) {
1131
+ throw new Error('Agent URL not configured. Add agentUrl to MCPWeb config to enable queries.');
1132
+ }
1133
+ if (!this.#connected) {
1134
+ throw new Error('Not connected to bridge. Ensure MCPWeb is connected before making queries.');
1135
+ }
1136
+ const parsedRequest = QueryRequestSchema.parse(request);
1137
+ const { prompt, context, responseTool, timeout, } = parsedRequest;
1138
+ // Validate that responseTool is registered if provided
1139
+ if (responseTool && !this.tools.has(responseTool.name)) {
1140
+ throw new Error(`Response tool '${responseTool.name}' is not registered. Register it with addTool() first.`);
1141
+ }
1142
+ // Generate a unique identifier for the query
1143
+ const uuid = globalThis.crypto.randomUUID();
1144
+ // Create internal AbortController if none provided
1145
+ const internalAbortController = signal ? undefined : new AbortController();
1146
+ const effectiveSignal = signal || internalAbortController?.signal;
1147
+ // Create the async generator that will be wrapped in Query instance
1148
+ const stream = this.createQueryStream(uuid, prompt, context, responseTool, timeout, effectiveSignal);
1149
+ // Create cancel function that works with both external and internal AbortController
1150
+ const cancelFn = () => {
1151
+ if (internalAbortController) {
1152
+ internalAbortController.abort();
1153
+ }
1154
+ else if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
1155
+ // If using external signal, send cancel directly to bridge
1156
+ this.#ws.send(JSON.stringify({
1157
+ type: 'query_cancel',
1158
+ uuid
1159
+ }));
1160
+ }
1161
+ };
1162
+ // Return QueryResponse instance with synchronous uuid access, async stream, and cancel function
1163
+ return new QueryResponse(uuid, stream, cancelFn);
1164
+ }
1165
+ /**
1166
+ * Internal method to create the query stream
1167
+ */
1168
+ async *createQueryStream(uuid, prompt, context, responseTool, timeout, signal) {
1169
+ // Process context items
1170
+ const processedContext = [];
1171
+ if (context) {
1172
+ for (const item of context) {
1173
+ if ('handler' in item) {
1174
+ // Tool definition
1175
+ processedContext.push({
1176
+ name: item.name,
1177
+ value: await item.handler(),
1178
+ schema: item.outputSchema ? toJSONSchema(item.outputSchema) : undefined,
1179
+ description: item.description,
1180
+ type: 'tool'
1181
+ });
1182
+ }
1183
+ else {
1184
+ // Ephemeral context
1185
+ processedContext.push({
1186
+ name: item.name,
1187
+ value: item.value,
1188
+ schema: item.schema,
1189
+ description: item.description,
1190
+ type: 'ephemeral'
1191
+ });
1192
+ }
1193
+ }
1194
+ }
1195
+ // Convert responseTool to serializable metadata
1196
+ const responseToolMetadata = responseTool ? toToolMetadataJson(responseTool) : undefined;
1197
+ // Send query message to bridge
1198
+ const queryMessage = QueryMessageSchema.parse({
1199
+ uuid,
1200
+ prompt,
1201
+ context: processedContext,
1202
+ responseTool: responseToolMetadata
1203
+ });
1204
+ this.#ws?.send(JSON.stringify(queryMessage));
1205
+ // Create async iterator for streaming events
1206
+ let completed = false;
1207
+ let timeoutId = null;
1208
+ let aborted = false;
1209
+ const eventQueue = [];
1210
+ let resolveNext = null;
1211
+ // Handle abort signal
1212
+ const handleAbort = () => {
1213
+ if (aborted || completed)
1214
+ return;
1215
+ aborted = true;
1216
+ completed = true;
1217
+ // Send cancellation message to bridge
1218
+ if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
1219
+ this.#ws.send(JSON.stringify({
1220
+ type: 'query_cancel',
1221
+ uuid
1222
+ }));
1223
+ }
1224
+ if (timeoutId)
1225
+ clearTimeout(timeoutId);
1226
+ this.#ws?.removeEventListener('message', handleMessage);
1227
+ // Yield a cancellation response
1228
+ const cancelResponse = {
1229
+ type: 'query_cancel',
1230
+ uuid,
1231
+ };
1232
+ if (resolveNext) {
1233
+ resolveNext({ value: cancelResponse, done: false });
1234
+ resolveNext = null;
1235
+ }
1236
+ else {
1237
+ eventQueue.push(cancelResponse);
1238
+ }
1239
+ };
1240
+ // Listen for abort signal
1241
+ if (signal) {
1242
+ if (signal.aborted) {
1243
+ handleAbort();
1244
+ }
1245
+ else {
1246
+ signal.addEventListener('abort', handleAbort);
1247
+ }
1248
+ }
1249
+ const handleMessage = (event) => {
1250
+ try {
1251
+ const message = JSON.parse(event.data);
1252
+ const parsedMessage = QueryResponseResultSchema.parse(message);
1253
+ if (parsedMessage.uuid === uuid) {
1254
+ if (parsedMessage.type === 'query_complete' || parsedMessage.type === 'query_failure') {
1255
+ completed = true;
1256
+ if (timeoutId)
1257
+ clearTimeout(timeoutId);
1258
+ this.#ws?.removeEventListener('message', handleMessage);
1259
+ if (signal)
1260
+ signal.removeEventListener('abort', handleAbort);
1261
+ }
1262
+ if (resolveNext) {
1263
+ resolveNext({ value: parsedMessage, done: false });
1264
+ resolveNext = null;
1265
+ }
1266
+ else {
1267
+ eventQueue.push(parsedMessage);
1268
+ }
1269
+ }
1270
+ }
1271
+ catch (_error) {
1272
+ // Ignore invalid JSON
1273
+ }
1274
+ };
1275
+ this.#ws?.addEventListener('message', handleMessage);
1276
+ // Set up timeout
1277
+ timeoutId = setTimeout(() => {
1278
+ completed = true;
1279
+ this.#ws?.removeEventListener('message', handleMessage);
1280
+ if (signal)
1281
+ signal.removeEventListener('abort', handleAbort);
1282
+ if (resolveNext) {
1283
+ resolveNext({ value: new Error('Query timeout'), done: true });
1284
+ resolveNext = null;
1285
+ }
1286
+ }, timeout);
1287
+ // Async iterator implementation
1288
+ const getNext = () => {
1289
+ return new Promise((resolve) => {
1290
+ if (eventQueue.length > 0) {
1291
+ // biome-ignore lint/style/noNonNullAssertion: We just checked that the length is greater than 0
1292
+ const event = eventQueue.shift();
1293
+ resolve({ value: event, done: false });
1294
+ }
1295
+ else if (completed) {
1296
+ resolve({ value: undefined, done: true });
1297
+ }
1298
+ else {
1299
+ resolveNext = resolve;
1300
+ }
1301
+ });
1302
+ };
1303
+ // Yield events as they arrive
1304
+ while (!completed) {
1305
+ const result = await getNext();
1306
+ if (result.done)
1307
+ break;
1308
+ yield result.value;
1309
+ }
1310
+ }
1311
+ }
1312
+ export default MCPWeb;