@cosmocoder/mcp-web-docs 2.0.20 → 2.0.22

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 (64) hide show
  1. package/build/config.d.ts +0 -19
  2. package/build/config.js +0 -50
  3. package/build/config.js.map +1 -1
  4. package/build/config.test.js +1 -31
  5. package/build/config.test.js.map +1 -1
  6. package/build/crawler/base.d.ts +1 -16
  7. package/build/crawler/base.js +4 -120
  8. package/build/crawler/base.js.map +1 -1
  9. package/build/crawler/base.test.js +2 -184
  10. package/build/crawler/base.test.js.map +1 -1
  11. package/build/crawler/crawlee-crawler.js +0 -1
  12. package/build/crawler/crawlee-crawler.js.map +1 -1
  13. package/build/crawler/crawlee-crawler.test.js +13 -0
  14. package/build/crawler/crawlee-crawler.test.js.map +1 -1
  15. package/build/crawler/docs-crawler.d.ts +4 -7
  16. package/build/crawler/docs-crawler.js +5 -12
  17. package/build/crawler/docs-crawler.js.map +1 -1
  18. package/build/crawler/docs-crawler.test.js +13 -51
  19. package/build/crawler/docs-crawler.test.js.map +1 -1
  20. package/build/crawler/github.d.ts +1 -1
  21. package/build/crawler/github.js +7 -7
  22. package/build/crawler/github.js.map +1 -1
  23. package/build/crawler/github.test.js +9 -39
  24. package/build/crawler/github.test.js.map +1 -1
  25. package/build/crawler/llms-txt.js +5 -3
  26. package/build/crawler/llms-txt.js.map +1 -1
  27. package/build/crawler/llms-txt.test.js +2 -0
  28. package/build/crawler/llms-txt.test.js.map +1 -1
  29. package/build/index.js +26 -1664
  30. package/build/index.js.map +1 -1
  31. package/build/index.test.js +267 -433
  32. package/build/index.test.js.map +1 -1
  33. package/build/indexing/queue-manager.d.ts +2 -0
  34. package/build/indexing/queue-manager.js +13 -18
  35. package/build/indexing/queue-manager.js.map +1 -1
  36. package/build/indexing/queue-manager.test.js +5 -12
  37. package/build/indexing/queue-manager.test.js.map +1 -1
  38. package/build/indexing/workflow.d.ts +38 -0
  39. package/build/indexing/workflow.js +223 -0
  40. package/build/indexing/workflow.js.map +1 -0
  41. package/build/indexing/workflow.test.d.ts +1 -0
  42. package/build/indexing/workflow.test.js +218 -0
  43. package/build/indexing/workflow.test.js.map +1 -0
  44. package/build/server.d.ts +88 -0
  45. package/build/server.js +1460 -0
  46. package/build/server.js.map +1 -0
  47. package/build/server.test.d.ts +1 -0
  48. package/build/server.test.js +27 -0
  49. package/build/server.test.js.map +1 -0
  50. package/build/storage/storage.js +1 -0
  51. package/build/storage/storage.js.map +1 -1
  52. package/build/storage/storage.test.js +25 -8
  53. package/build/storage/storage.test.js.map +1 -1
  54. package/build/types.d.ts +0 -15
  55. package/build/util/docs.js +1 -2
  56. package/build/util/docs.js.map +1 -1
  57. package/build/util/docs.test.js +8 -2
  58. package/build/util/docs.test.js.map +1 -1
  59. package/build/util/security.d.ts +1 -0
  60. package/build/util/security.js +6 -5
  61. package/build/util/security.js.map +1 -1
  62. package/build/util/security.test.js +7 -1
  63. package/build/util/security.test.js.map +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,1460 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
5
+ import { DocumentStore } from './storage/storage.js';
6
+ import { FastEmbeddings } from './embeddings/fastembed.js';
7
+ import { WebDocumentProcessor } from './processor/processor.js';
8
+ import { IndexingStatusTracker } from './indexing/status.js';
9
+ import { IndexingQueueManager } from './indexing/queue-manager.js';
10
+ import { IndexingWorkflow } from './indexing/workflow.js';
11
+ import { loadConfig, isValidPublicUrl, normalizeUrl } from './config.js';
12
+ import { AuthManager } from './crawler/auth.js';
13
+ import { fetchFavicon } from './util/favicon.js';
14
+ import { generateCrawlStorageId, generateDocId } from './util/docs.js';
15
+ import { logger } from './util/logger.js';
16
+ import { closeOutboundProxy } from './util/outbound-request.js';
17
+ import { validateToolArgs, sanitizeErrorMessage, detectPromptInjection, wrapExternalContent, addInjectionWarnings, AddDocumentationArgsSchema, AuthenticateArgsSchema, ClearAuthArgsSchema, SearchDocumentationArgsSchema, ReindexDocumentationArgsSchema, DeleteDocumentationArgsSchema, SetTagsArgsSchema, CreateCollectionArgsSchema, DeleteCollectionArgsSchema, UpdateCollectionArgsSchema, GetCollectionArgsSchema, AddToCollectionArgsSchema, RemoveFromCollectionArgsSchema, SearchCollectionArgsSchema, } from './util/security.js';
18
+ export class WebDocsServer {
19
+ server;
20
+ config;
21
+ store;
22
+ processor;
23
+ statusTracker;
24
+ indexingQueue;
25
+ authManager;
26
+ indexingWorkflow;
27
+ runPromise;
28
+ closePromise;
29
+ activeToolCalls = new Set();
30
+ /** Maps operation ID to progress token for MCP notifications */
31
+ progressTokens = new Map();
32
+ /** Tracks last notified progress to throttle notifications */
33
+ lastNotifiedProgress = new Map();
34
+ constructor() {
35
+ // Initialize basic components that don't need async initialization
36
+ this.statusTracker = new IndexingStatusTracker();
37
+ this.indexingQueue = new IndexingQueueManager();
38
+ // Set up status change listener for MCP progress notifications
39
+ this.statusTracker.addStatusListener((status) => {
40
+ this.sendProgressNotification(status);
41
+ });
42
+ // Initialize MCP server
43
+ this.server = new McpServer({
44
+ name: 'mcp-web-docs',
45
+ version: '1.0.0',
46
+ }, {
47
+ capabilities: {
48
+ tools: {},
49
+ },
50
+ });
51
+ // Set up tool handlers
52
+ this.setupToolHandlers();
53
+ // Handle errors
54
+ this.server.server.onerror = (error) => logger.error('[MCP Error]', error);
55
+ }
56
+ /**
57
+ * Send MCP progress notification to client.
58
+ * Only sends if the client provided a progressToken in the original request.
59
+ * Throttled to avoid flooding - sends on 5% increments or status changes.
60
+ */
61
+ async sendProgressNotification(status) {
62
+ const registration = this.progressTokens.get(status.operationId);
63
+ // Only send if we have a progress token from the client
64
+ if (!registration) {
65
+ logger.debug(`[Progress] No token for ${status.operationId}, skipping notification`);
66
+ return;
67
+ }
68
+ const { token: progressToken } = registration;
69
+ const progressPercent = Math.round(status.progress * 100);
70
+ const lastProgress = this.lastNotifiedProgress.get(status.operationId) ?? -1;
71
+ // Only notify on significant progress (5% increments) or status changes
72
+ const isStatusChange = status.status === 'complete' || status.status === 'failed' || status.status === 'cancelled';
73
+ const isSignificantProgress = progressPercent - lastProgress >= 5;
74
+ if (!isStatusChange && !isSignificantProgress) {
75
+ return;
76
+ }
77
+ this.lastNotifiedProgress.set(status.operationId, progressPercent);
78
+ // Build human-readable message
79
+ let message = status.description;
80
+ if (status.pagesProcessed !== undefined && status.pagesFound !== undefined) {
81
+ message = `${status.description} (${status.pagesProcessed}/${status.pagesFound} pages)`;
82
+ }
83
+ try {
84
+ // Send MCP progress notification per spec:
85
+ // https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress
86
+ await this.server.server.notification({
87
+ method: 'notifications/progress',
88
+ params: {
89
+ progressToken,
90
+ progress: progressPercent,
91
+ total: 100,
92
+ message,
93
+ },
94
+ });
95
+ logger.info(`[Progress] Sent notification: ${progressPercent}% - ${message}`);
96
+ }
97
+ catch (error) {
98
+ logger.debug(`[Progress] Failed to send notification:`, error);
99
+ }
100
+ // Clean up tracking for completed operations
101
+ if (isStatusChange && this.progressTokens.get(status.operationId) === registration) {
102
+ this.lastNotifiedProgress.delete(status.operationId);
103
+ this.progressTokens.delete(status.operationId);
104
+ }
105
+ }
106
+ async initialize() {
107
+ // Load configuration
108
+ this.config = await loadConfig();
109
+ // Initialize components that need config
110
+ const embeddings = new FastEmbeddings();
111
+ this.store = new DocumentStore(this.config.dbPath, this.config.vectorDbPath, embeddings, this.config.cacheSize);
112
+ this.processor = new WebDocumentProcessor(embeddings, this.config.maxChunkSize);
113
+ // Initialize auth manager for handling authenticated crawls
114
+ this.authManager = new AuthManager(this.config.dataDir);
115
+ await this.authManager.initialize();
116
+ const { DocsCrawler } = await import('./crawler/docs-crawler.js');
117
+ this.indexingWorkflow = new IndexingWorkflow({
118
+ store: this.store,
119
+ processor: this.processor,
120
+ statusTracker: this.statusTracker,
121
+ authManager: this.authManager,
122
+ createCrawler: () => new DocsCrawler(this.config.githubToken),
123
+ fetchFavicon,
124
+ });
125
+ // Initialize storage
126
+ await this.store.initialize();
127
+ }
128
+ setupToolHandlers() {
129
+ // List available tools
130
+ this.server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
131
+ tools: [
132
+ {
133
+ name: 'add_documentation',
134
+ description: `Add new documentation site for indexing. Supports authenticated sites via the auth options.
135
+
136
+ IMPORTANT: Before calling this tool, ask the user if they want to restrict crawling to a specific path prefix. For example, if indexing https://docs.example.com/api/v2/overview, the user might want to restrict to '/api/v2' to avoid crawling unrelated sections of the site.
137
+
138
+ VERSIONING: If the user is indexing documentation for a versioned software package/library (e.g., React, Vue, Python, a database, an SDK), ask what version they want to associate with this documentation. Many packages have multiple versions with different APIs.
139
+
140
+ Do NOT ask about versioning for:
141
+ - Internal company documentation (wikis, best practices, runbooks)
142
+ - Single-version products or services
143
+ - Documentation the user indicates should always reflect "latest"
144
+
145
+ Examples where version matters: "React 18", "Python 3.11", "PostgreSQL 15", "Next.js 14"
146
+ Examples where version doesn't matter: "Company engineering handbook", "AWS console docs", "Confluence spaces"`,
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ url: {
151
+ type: 'string',
152
+ description: 'URL of the documentation site',
153
+ },
154
+ title: {
155
+ type: 'string',
156
+ description: 'Optional title for the documentation',
157
+ },
158
+ id: {
159
+ type: 'string',
160
+ description: 'Optional document ID returned for display and compatibility. If not provided, an ID is auto-generated from the URL.',
161
+ },
162
+ pathPrefix: {
163
+ type: 'string',
164
+ description: "Optional path prefix to restrict crawling. Only pages whose URL path starts with this prefix will be indexed. Must start with '/'. Example: '/api/v2' would only crawl pages under that path.",
165
+ },
166
+ tags: {
167
+ type: 'array',
168
+ items: { type: 'string' },
169
+ description: 'Optional tags to categorize the documentation (e.g., ["frontend", "mycompany"]). Tags help filter search results across multiple documentation sites.',
170
+ },
171
+ version: {
172
+ type: 'string',
173
+ description: 'Optional version identifier for versioned package documentation (e.g., "18", "v6.4", "3.11", "latest"). Helps distinguish between multiple versions of the same package.',
174
+ },
175
+ auth: {
176
+ type: 'object',
177
+ description: 'Authentication options for protected documentation sites',
178
+ properties: {
179
+ requiresAuth: {
180
+ type: 'boolean',
181
+ description: 'Set to true to open a browser for interactive login before crawling',
182
+ },
183
+ browser: {
184
+ type: 'string',
185
+ enum: ['chromium', 'chrome', 'firefox', 'webkit', 'edge'],
186
+ description: "Optional. If omitted, the user's default browser is automatically detected from OS settings. Only specify to override.",
187
+ },
188
+ loginUrl: {
189
+ type: 'string',
190
+ description: 'Login page URL if different from main URL',
191
+ },
192
+ loginSuccessPattern: {
193
+ type: 'string',
194
+ description: 'URL regex pattern that indicates successful login',
195
+ },
196
+ loginSuccessSelector: {
197
+ type: 'string',
198
+ description: 'CSS selector that appears after successful login',
199
+ },
200
+ loginTimeoutSecs: {
201
+ type: 'number',
202
+ description: 'Timeout for login in seconds (default: 300)',
203
+ },
204
+ },
205
+ },
206
+ },
207
+ required: ['url'],
208
+ },
209
+ },
210
+ {
211
+ name: 'authenticate',
212
+ description: "Open a browser window for interactive login to a protected site. The session will be saved and reused for future crawls. Use this before add_documentation for sites that require login. The user's default browser is automatically detected from OS settings - do NOT specify a browser unless the user explicitly requests a specific one.",
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ url: {
217
+ type: 'string',
218
+ description: 'URL of the site to authenticate to',
219
+ },
220
+ browser: {
221
+ type: 'string',
222
+ enum: ['chromium', 'chrome', 'firefox', 'webkit', 'edge'],
223
+ description: "Optional. If omitted, the user's default browser is automatically detected from OS settings. Only specify this to override auto-detection with a specific browser.",
224
+ },
225
+ loginUrl: {
226
+ type: 'string',
227
+ description: 'Login page URL if different from main URL',
228
+ },
229
+ loginTimeoutSecs: {
230
+ type: 'number',
231
+ description: 'Timeout for login in seconds (default: 300 = 5 minutes)',
232
+ },
233
+ },
234
+ required: ['url'],
235
+ },
236
+ },
237
+ {
238
+ name: 'clear_auth',
239
+ description: 'Clear saved authentication session for a domain',
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ url: {
244
+ type: 'string',
245
+ description: 'URL of the site to clear authentication for',
246
+ },
247
+ },
248
+ required: ['url'],
249
+ },
250
+ },
251
+ {
252
+ name: 'list_documentation',
253
+ description: 'List all indexed documentation sites with their metadata including tags. Use this to see what documentation is available and what tags are assigned to each site. Each doc shows: url, title, tags[], lastIndexed, requiresAuth.',
254
+ inputSchema: {
255
+ type: 'object',
256
+ properties: {},
257
+ },
258
+ },
259
+ {
260
+ name: 'search_documentation',
261
+ description: `Search through indexed documentation using hybrid search (full-text + semantic).
262
+
263
+ ## Query Tips for Best Results
264
+
265
+ 1. **Be specific** - Include unique terms from what you're looking for
266
+ - Instead of: "Button props"
267
+ - Try: "Button props onClick disabled loading"
268
+
269
+ 2. **Use exact phrases** - Wrap in quotes for exact matching
270
+ - "authentication middleware" finds that exact phrase
271
+ - authentication middleware finds pages with either word
272
+
273
+ 3. **Include context** - Add related terms to narrow results
274
+ - API docs: "GET /users endpoint authentication headers"
275
+ - Config: "webpack config entry output plugins"
276
+ - Functions: "parseJSON function parameters return type"
277
+
278
+ 4. **Combine concepts** - More terms = more precise results
279
+ - "Card component status primary negative props table"
280
+ - "database connection pool maxConnections timeout"
281
+
282
+ ## Filtering Options
283
+
284
+ - **url**: Filter to a specific documentation site by URL
285
+ - **tags**: Filter to docs with specific tags. Use when user mentions a category, project, or team name (e.g., tags: ["frontend", "jimdo"] to search only frontend Jimdo docs)
286
+
287
+ ## How Search Works
288
+ - Full-text search with stemming (run → runs, running)
289
+ - Fuzzy matching for typos (authetication → authentication)
290
+ - Semantic similarity for conceptual matches
291
+ - Results ranked by relevance combining all signals`,
292
+ inputSchema: {
293
+ type: 'object',
294
+ properties: {
295
+ query: {
296
+ type: 'string',
297
+ description: 'Search query - be specific and include unique terms. Use quotes for exact phrases. Example: "Card component props headline status" or "REST API authentication Bearer token"',
298
+ },
299
+ url: {
300
+ type: 'string',
301
+ description: 'Optional: Filter results to a specific documentation site by its URL. If not provided, searches all indexed docs.',
302
+ },
303
+ limit: {
304
+ type: 'number',
305
+ description: 'Maximum number of results (default: 10)',
306
+ },
307
+ tags: {
308
+ type: 'array',
309
+ items: { type: 'string' },
310
+ description: 'Optional: Filter to docs with ALL specified tags. Use when user mentions a category, project, or team (e.g., ["frontend", "mycompany"]). See list_tags for available tags.',
311
+ },
312
+ },
313
+ required: ['query'],
314
+ },
315
+ },
316
+ {
317
+ name: 'reindex_documentation',
318
+ description: 'Re-index a specific documentation site. By default, preserves its existing path prefix. Provide pathPrefix to override it, or null to remove the restriction.',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ url: {
323
+ type: 'string',
324
+ description: 'URL of the documentation to re-index',
325
+ },
326
+ pathPrefix: {
327
+ type: ['string', 'null'],
328
+ description: "Optional path prefix override. Must start with '/'. Omit to preserve the existing prefix, or pass null to crawl without a path restriction.",
329
+ },
330
+ },
331
+ required: ['url'],
332
+ },
333
+ },
334
+ {
335
+ name: 'get_indexing_status',
336
+ description: 'Get current indexing status',
337
+ inputSchema: {
338
+ type: 'object',
339
+ properties: {},
340
+ },
341
+ },
342
+ {
343
+ name: 'delete_documentation',
344
+ description: 'Delete an indexed documentation site and all its data (vectors, metadata, cached crawl data, and optionally auth session)',
345
+ inputSchema: {
346
+ type: 'object',
347
+ properties: {
348
+ url: {
349
+ type: 'string',
350
+ description: 'URL of the documentation site to delete',
351
+ },
352
+ clearAuth: {
353
+ type: 'boolean',
354
+ description: 'Also clear saved authentication session for this domain (default: false)',
355
+ },
356
+ },
357
+ required: ['url'],
358
+ },
359
+ },
360
+ {
361
+ name: 'set_tags',
362
+ description: 'Set tags for a documentation site to enable tag-based filtering in searches. Tags categorize docs by project, team, or type (e.g., "frontend", "backend", "mycompany", "jimdo"). Replaces any existing tags. Use an empty array to remove all tags.',
363
+ inputSchema: {
364
+ type: 'object',
365
+ properties: {
366
+ url: {
367
+ type: 'string',
368
+ description: 'URL of the documentation site',
369
+ },
370
+ tags: {
371
+ type: 'array',
372
+ items: { type: 'string' },
373
+ description: 'Array of tags to assign. Tags are case-insensitive and must contain only alphanumeric characters, hyphens, or underscores. Example: ["frontend", "mycompany", "react"]',
374
+ },
375
+ },
376
+ required: ['url', 'tags'],
377
+ },
378
+ },
379
+ {
380
+ name: 'list_tags',
381
+ description: 'List all available tags with usage counts. Use this to discover what tags exist when you need to filter searches but are unsure of the exact tag names. Returns tags sorted by usage count.',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {},
385
+ },
386
+ },
387
+ // ============ Collection Tools ============
388
+ {
389
+ name: 'create_collection',
390
+ description: 'Create a new collection to group related documentation sites. Collections help organize docs by project or context (e.g., "My React Project" with React + Next.js + TypeScript docs).',
391
+ inputSchema: {
392
+ type: 'object',
393
+ properties: {
394
+ name: {
395
+ type: 'string',
396
+ description: 'Unique name for the collection (e.g., "My React Project", "Backend APIs")',
397
+ },
398
+ description: {
399
+ type: 'string',
400
+ description: 'Optional description of what this collection contains',
401
+ },
402
+ },
403
+ required: ['name'],
404
+ },
405
+ },
406
+ {
407
+ name: 'delete_collection',
408
+ description: 'Delete a collection. The documentation sites in the collection are NOT deleted, only the collection grouping.',
409
+ inputSchema: {
410
+ type: 'object',
411
+ properties: {
412
+ name: {
413
+ type: 'string',
414
+ description: 'Name of the collection to delete',
415
+ },
416
+ },
417
+ required: ['name'],
418
+ },
419
+ },
420
+ {
421
+ name: 'update_collection',
422
+ description: "Update a collection's name or description.",
423
+ inputSchema: {
424
+ type: 'object',
425
+ properties: {
426
+ name: {
427
+ type: 'string',
428
+ description: 'Current name of the collection',
429
+ },
430
+ newName: {
431
+ type: 'string',
432
+ description: 'Optional new name for the collection',
433
+ },
434
+ description: {
435
+ type: 'string',
436
+ description: 'Optional new description for the collection',
437
+ },
438
+ },
439
+ required: ['name'],
440
+ },
441
+ },
442
+ {
443
+ name: 'list_collections',
444
+ description: 'List all collections with their document counts. Use this to see available collections for context switching.',
445
+ inputSchema: {
446
+ type: 'object',
447
+ properties: {},
448
+ },
449
+ },
450
+ {
451
+ name: 'get_collection',
452
+ description: 'Get details of a specific collection including all its documentation sites.',
453
+ inputSchema: {
454
+ type: 'object',
455
+ properties: {
456
+ name: {
457
+ type: 'string',
458
+ description: 'Name of the collection',
459
+ },
460
+ },
461
+ required: ['name'],
462
+ },
463
+ },
464
+ {
465
+ name: 'add_to_collection',
466
+ description: 'Add one or more documentation sites to a collection. Sites must already be indexed.',
467
+ inputSchema: {
468
+ type: 'object',
469
+ properties: {
470
+ name: {
471
+ type: 'string',
472
+ description: 'Name of the collection',
473
+ },
474
+ urls: {
475
+ type: 'array',
476
+ items: { type: 'string' },
477
+ description: 'URLs of indexed documentation sites to add (max 50)',
478
+ },
479
+ },
480
+ required: ['name', 'urls'],
481
+ },
482
+ },
483
+ {
484
+ name: 'remove_from_collection',
485
+ description: 'Remove one or more documentation sites from a collection. The sites remain indexed, just removed from the collection.',
486
+ inputSchema: {
487
+ type: 'object',
488
+ properties: {
489
+ name: {
490
+ type: 'string',
491
+ description: 'Name of the collection',
492
+ },
493
+ urls: {
494
+ type: 'array',
495
+ items: { type: 'string' },
496
+ description: 'URLs of documentation sites to remove from the collection',
497
+ },
498
+ },
499
+ required: ['name', 'urls'],
500
+ },
501
+ },
502
+ {
503
+ name: 'search_collection',
504
+ description: 'Search for documentation within a specific collection. This is useful for focused searches within a project context. Uses the same hybrid search (full-text + semantic) as search_documentation.',
505
+ inputSchema: {
506
+ type: 'object',
507
+ properties: {
508
+ name: {
509
+ type: 'string',
510
+ description: 'Name of the collection to search in',
511
+ },
512
+ query: {
513
+ type: 'string',
514
+ description: 'Search query - be specific and include unique terms',
515
+ },
516
+ limit: {
517
+ type: 'number',
518
+ description: 'Maximum number of results (default: 10)',
519
+ },
520
+ },
521
+ required: ['name', 'query'],
522
+ },
523
+ },
524
+ ],
525
+ }));
526
+ // Handle tool calls
527
+ this.server.server.setRequestHandler(CallToolRequestSchema, (request) => {
528
+ const call = (async () => {
529
+ const progressToken = request.params._meta?.progressToken;
530
+ switch (request.params.name) {
531
+ case 'add_documentation':
532
+ return this.handleAddDocumentation(request.params.arguments, progressToken);
533
+ case 'list_documentation':
534
+ return this.handleListDocumentation();
535
+ case 'search_documentation':
536
+ return this.handleSearchDocumentation(request.params.arguments);
537
+ case 'reindex_documentation':
538
+ return this.handleReindexDocumentation(request.params.arguments, progressToken);
539
+ case 'get_indexing_status':
540
+ return this.handleGetIndexingStatus();
541
+ case 'authenticate':
542
+ return this.handleAuthenticate(request.params.arguments);
543
+ case 'clear_auth':
544
+ return this.handleClearAuth(request.params.arguments);
545
+ case 'delete_documentation':
546
+ return this.handleDeleteDocumentation(request.params.arguments);
547
+ case 'set_tags':
548
+ return this.handleSetTags(request.params.arguments);
549
+ case 'list_tags':
550
+ return this.handleListTags();
551
+ case 'create_collection':
552
+ return this.handleCreateCollection(request.params.arguments);
553
+ case 'delete_collection':
554
+ return this.handleDeleteCollection(request.params.arguments);
555
+ case 'update_collection':
556
+ return this.handleUpdateCollection(request.params.arguments);
557
+ case 'list_collections':
558
+ return this.handleListCollections();
559
+ case 'get_collection':
560
+ return this.handleGetCollection(request.params.arguments);
561
+ case 'add_to_collection':
562
+ return this.handleAddToCollection(request.params.arguments);
563
+ case 'remove_from_collection':
564
+ return this.handleRemoveFromCollection(request.params.arguments);
565
+ case 'search_collection':
566
+ return this.handleSearchCollection(request.params.arguments);
567
+ default:
568
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
569
+ }
570
+ })();
571
+ this.activeToolCalls.add(call);
572
+ return call.finally(() => this.activeToolCalls.delete(call));
573
+ });
574
+ }
575
+ async handleAddDocumentation(args, progressToken) {
576
+ // Validate arguments with schema
577
+ let validatedArgs;
578
+ try {
579
+ validatedArgs = validateToolArgs(args, AddDocumentationArgsSchema);
580
+ }
581
+ catch (error) {
582
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
583
+ }
584
+ const { url, title, id, pathPrefix, tags, version, auth: authOptions } = validatedArgs;
585
+ // Additional SSRF protection check
586
+ if (!isValidPublicUrl(url)) {
587
+ throw new McpError(ErrorCode.InvalidParams, 'Access to private networks is blocked');
588
+ }
589
+ const normalizedUrl = normalizeUrl(url);
590
+ const docTitle = title || new URL(normalizedUrl).hostname;
591
+ // Use custom ID if provided, otherwise auto-generate
592
+ const docId = id || generateDocId(normalizedUrl, docTitle);
593
+ // Log path prefix if provided
594
+ if (pathPrefix) {
595
+ logger.info(`[WebDocsServer] Path prefix restriction: ${pathPrefix}`);
596
+ }
597
+ if (authOptions?.requiresAuth) {
598
+ const hasExistingSession = await this.authManager.hasSession(normalizedUrl);
599
+ if (!hasExistingSession) {
600
+ logger.info(`[WebDocsServer] auth.requiresAuth=true, starting interactive login for ${normalizedUrl}`);
601
+ try {
602
+ await this.authManager.performInteractiveLogin(normalizedUrl, {
603
+ browser: authOptions.browser,
604
+ loginUrl: authOptions.loginUrl,
605
+ loginSuccessPattern: authOptions.loginSuccessPattern,
606
+ loginSuccessSelector: authOptions.loginSuccessSelector,
607
+ loginTimeoutSecs: authOptions.loginTimeoutSecs,
608
+ });
609
+ logger.info(`[WebDocsServer] Authentication successful for ${normalizedUrl}`);
610
+ }
611
+ catch (error) {
612
+ throw new McpError(ErrorCode.InternalError, `Authentication failed: ${sanitizeErrorMessage(error)}. Please try using the 'authenticate' tool separately.`);
613
+ }
614
+ }
615
+ else {
616
+ // Validate that the existing session is still valid before crawling
617
+ logger.info(`[WebDocsServer] Validating existing session for ${normalizedUrl}...`);
618
+ const validation = await this.authManager.validateSession(normalizedUrl);
619
+ if (!validation.isValid) {
620
+ logger.warn(`[WebDocsServer] Session expired for ${normalizedUrl}: ${validation.reason}`);
621
+ // Clear the expired session
622
+ await this.authManager.clearSession(normalizedUrl);
623
+ throw new McpError(ErrorCode.InvalidParams, `Authentication session has expired (${validation.reason}). Please use the 'authenticate' tool to log in again.`);
624
+ }
625
+ logger.info(`[WebDocsServer] ✓ Session validated for ${normalizedUrl}`);
626
+ }
627
+ }
628
+ // Prepare auth info to store with the document
629
+ // If auth was explicitly requested OR if there's an existing session for this URL,
630
+ // mark the document as requiring auth (handles case where user called authenticate separately)
631
+ const hasExistingSession = await this.authManager.hasSession(normalizedUrl);
632
+ const requiresAuth = authOptions?.requiresAuth || hasExistingSession;
633
+ const authInfo = requiresAuth
634
+ ? {
635
+ requiresAuth: true,
636
+ authDomain: new URL(normalizedUrl).hostname, // Session is stored under target URL's domain
637
+ }
638
+ : undefined;
639
+ if (hasExistingSession && !authOptions?.requiresAuth) {
640
+ logger.info(`[WebDocsServer] Found existing auth session for ${normalizedUrl}, marking document as requiring auth`);
641
+ }
642
+ const operationId = randomUUID();
643
+ const operation = await this.indexingQueue.runLatest(normalizedUrl, async (signal) => {
644
+ if (progressToken !== undefined) {
645
+ this.progressTokens.set(operationId, { token: progressToken });
646
+ logger.info(`[Progress] Registered token for ${operationId}: ${progressToken}`);
647
+ }
648
+ this.statusTracker.startIndexing(operationId, docId, normalizedUrl, docTitle);
649
+ await this.indexingWorkflow.run({ operationId, url: normalizedUrl, title: docTitle, pathPrefix, authInfo, tags, version }, signal);
650
+ });
651
+ void operation.completion.catch((error) => {
652
+ const err = error;
653
+ if (err?.name !== 'AbortError') {
654
+ logger.error('[WebDocsServer] Background indexing failed:', error);
655
+ }
656
+ });
657
+ return {
658
+ content: [
659
+ {
660
+ type: 'text',
661
+ text: JSON.stringify({
662
+ status: 'started',
663
+ message: `Started indexing ${normalizedUrl}`,
664
+ docId,
665
+ operationId,
666
+ instruction: 'IMPORTANT: You MUST call get_indexing_status repeatedly (every few seconds) until status is "complete" or "failed". Do not ask the user - just keep polling automatically.',
667
+ }, null, 2),
668
+ },
669
+ ],
670
+ };
671
+ }
672
+ async handleListDocumentation() {
673
+ const docs = await this.store.listDocuments();
674
+ return {
675
+ content: [
676
+ {
677
+ type: 'text',
678
+ text: JSON.stringify(docs, null, 2),
679
+ },
680
+ ],
681
+ };
682
+ }
683
+ async handleSearchDocumentation(args) {
684
+ // Validate arguments with schema
685
+ let validatedArgs;
686
+ try {
687
+ validatedArgs = validateToolArgs(args, SearchDocumentationArgsSchema);
688
+ }
689
+ catch (error) {
690
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
691
+ }
692
+ const { query, url, limit = 10, tags } = validatedArgs;
693
+ // Normalize URL if provided for filtering
694
+ const filterUrl = url ? normalizeUrl(url) : undefined;
695
+ const results = await this.store.searchByText(query, { limit, filterUrl, filterByTags: tags });
696
+ // Apply prompt injection detection and filter/process results
697
+ let blockedCount = 0;
698
+ const safeResults = results
699
+ .map((result) => {
700
+ // Detect prompt injection patterns in the content
701
+ // Note: detectPromptInjection strips code blocks before scanning,
702
+ // so legitimate code examples won't trigger false positives
703
+ const injectionResult = detectPromptInjection(result.content);
704
+ // SECURITY: Block results with high-severity injection patterns
705
+ // These could manipulate the LLM if returned
706
+ if (injectionResult.maxSeverity === 'high') {
707
+ blockedCount++;
708
+ logger.debug(`[Security] Blocked search result from ${result.url} due to high-severity injection pattern: ${injectionResult.detections[0]?.description}`);
709
+ return null; // Will be filtered out
710
+ }
711
+ // For medium/low severity, add warnings but still return
712
+ let safeContent = addInjectionWarnings(result.content, injectionResult);
713
+ // Wrap with external content markers
714
+ safeContent = wrapExternalContent(safeContent, result.url);
715
+ return {
716
+ ...result,
717
+ content: safeContent,
718
+ // Include security metadata
719
+ security: {
720
+ isExternalContent: true,
721
+ injectionDetected: injectionResult.hasInjection,
722
+ injectionSeverity: injectionResult.maxSeverity,
723
+ detectionCount: injectionResult.detections.length,
724
+ },
725
+ };
726
+ })
727
+ .filter((result) => result !== null);
728
+ // Build response with security notice if content was blocked
729
+ const response = {
730
+ results: safeResults,
731
+ };
732
+ if (blockedCount > 0) {
733
+ response.securityNotice = `${blockedCount} result(s) were blocked due to high-severity prompt injection patterns detected in the content. This protects against potentially malicious content that could manipulate AI behavior.`;
734
+ }
735
+ return {
736
+ content: [
737
+ {
738
+ type: 'text',
739
+ text: JSON.stringify(response, null, 2),
740
+ },
741
+ ],
742
+ };
743
+ }
744
+ async handleReindexDocumentation(args, progressToken) {
745
+ // Validate arguments with schema
746
+ let validatedArgs;
747
+ try {
748
+ validatedArgs = validateToolArgs(args, ReindexDocumentationArgsSchema);
749
+ }
750
+ catch (error) {
751
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
752
+ }
753
+ const { url, pathPrefix: pathPrefixOverride } = validatedArgs;
754
+ // Additional SSRF protection check
755
+ if (!isValidPublicUrl(url)) {
756
+ throw new McpError(ErrorCode.InvalidParams, 'Access to private networks is blocked');
757
+ }
758
+ const normalizedUrl = normalizeUrl(url);
759
+ const doc = await this.store.getDocument(normalizedUrl);
760
+ if (!doc) {
761
+ throw new McpError(ErrorCode.InvalidParams, 'Documentation not found');
762
+ }
763
+ // Check if this site was originally indexed with authentication
764
+ // If so, we MUST have a valid session to reindex
765
+ if (doc.requiresAuth) {
766
+ const authDomain = doc.authDomain || new URL(normalizedUrl).hostname;
767
+ logger.info(`[WebDocsServer] Site requires auth (authDomain: ${authDomain}). Validating session...`);
768
+ // Check if we have a session for this auth domain
769
+ const hasSession = await this.authManager.hasSession(normalizedUrl);
770
+ if (!hasSession) {
771
+ throw new McpError(ErrorCode.InvalidParams, `This documentation site requires authentication but no session was found. Please use the 'authenticate' tool to log in before re-indexing.`);
772
+ }
773
+ // Validate the session is still valid
774
+ const validation = await this.authManager.validateSession(normalizedUrl);
775
+ if (!validation.isValid) {
776
+ logger.warn(`[WebDocsServer] Session expired for ${normalizedUrl}: ${validation.reason}`);
777
+ // Clear the expired session
778
+ await this.authManager.clearSession(normalizedUrl);
779
+ throw new McpError(ErrorCode.InvalidParams, `Authentication session has expired (${validation.reason}). Please use the 'authenticate' tool to log in again before re-indexing.`);
780
+ }
781
+ logger.info(`[WebDocsServer] ✓ Session validated for ${normalizedUrl}`);
782
+ }
783
+ // Prepare auth info to preserve with reindexed document
784
+ const authInfo = doc.requiresAuth
785
+ ? {
786
+ requiresAuth: true,
787
+ authDomain: doc.authDomain || new URL(normalizedUrl).hostname,
788
+ }
789
+ : undefined;
790
+ // Preserve existing crawl settings during reindex
791
+ const existingTags = doc.tags;
792
+ const existingVersion = doc.version;
793
+ const pathPrefix = pathPrefixOverride === undefined ? (doc.pathPrefix ?? undefined) : (pathPrefixOverride ?? undefined);
794
+ const docId = generateDocId(normalizedUrl, doc.title);
795
+ const operationId = randomUUID();
796
+ const operation = await this.indexingQueue.runLatest(normalizedUrl, async (signal) => {
797
+ if (progressToken !== undefined) {
798
+ this.progressTokens.set(operationId, { token: progressToken });
799
+ logger.info(`[Progress] Registered token for ${operationId}: ${progressToken}`);
800
+ }
801
+ this.statusTracker.startIndexing(operationId, docId, normalizedUrl, doc.title);
802
+ await this.indexingWorkflow.run({
803
+ operationId,
804
+ url: normalizedUrl,
805
+ title: doc.title,
806
+ reIndex: true,
807
+ pathPrefix,
808
+ authInfo,
809
+ tags: existingTags,
810
+ version: existingVersion,
811
+ }, signal);
812
+ });
813
+ void operation.completion.catch((error) => {
814
+ const err = error;
815
+ if (err?.name !== 'AbortError') {
816
+ logger.error('[WebDocsServer] Background reindexing failed:', error);
817
+ }
818
+ });
819
+ return {
820
+ content: [
821
+ {
822
+ type: 'text',
823
+ text: JSON.stringify({
824
+ status: 'started',
825
+ message: operation.replacedExisting
826
+ ? `Started re-indexing ${normalizedUrl}. Previous operation was cancelled.`
827
+ : `Started re-indexing ${normalizedUrl}`,
828
+ docId,
829
+ operationId,
830
+ instruction: 'IMPORTANT: You MUST call get_indexing_status repeatedly (every few seconds) until status is "complete" or "failed". Do not ask the user - just keep polling automatically.',
831
+ }, null, 2),
832
+ },
833
+ ],
834
+ };
835
+ }
836
+ handleGetIndexingStatus() {
837
+ // Get only active operations and recently completed ones (auto-cleans old statuses)
838
+ const statuses = this.statusTracker.getActiveStatuses();
839
+ // Check if any operations are still in progress
840
+ const hasActiveOperations = statuses.some((s) => s.status === 'indexing');
841
+ // Add instruction for agent
842
+ const response = {
843
+ statuses,
844
+ instruction: hasActiveOperations
845
+ ? 'Operations still in progress. Call get_indexing_status again in a few seconds to check progress.'
846
+ : 'All operations complete. No need to poll again.',
847
+ };
848
+ return {
849
+ content: [
850
+ {
851
+ type: 'text',
852
+ text: JSON.stringify(response, null, 2),
853
+ },
854
+ ],
855
+ };
856
+ }
857
+ /**
858
+ * Handle interactive authentication request.
859
+ * Opens a visible browser for the user to login manually.
860
+ */
861
+ async handleAuthenticate(args) {
862
+ // Validate arguments with schema
863
+ let validatedArgs;
864
+ try {
865
+ validatedArgs = validateToolArgs(args, AuthenticateArgsSchema);
866
+ }
867
+ catch (error) {
868
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
869
+ }
870
+ const { url, browser, loginUrl, loginTimeoutSecs = 300 } = validatedArgs;
871
+ // Additional SSRF protection check
872
+ if (!isValidPublicUrl(url)) {
873
+ throw new McpError(ErrorCode.InvalidParams, 'Access to private networks is blocked');
874
+ }
875
+ const normalizedUrl = normalizeUrl(url);
876
+ const domain = new URL(normalizedUrl).hostname;
877
+ // Check if we already have a session and validate it
878
+ const hasSession = await this.authManager.hasSession(normalizedUrl);
879
+ if (hasSession) {
880
+ // Validate that the existing session is still valid
881
+ logger.info(`[Auth] Validating existing session for ${domain}...`);
882
+ const validation = await this.authManager.validateSession(normalizedUrl);
883
+ if (validation.isValid) {
884
+ return {
885
+ content: [
886
+ {
887
+ type: 'text',
888
+ text: JSON.stringify({
889
+ status: 'existing_session',
890
+ message: `Already have a valid saved session for ${domain}. Use clear_auth first if you need to re-authenticate.`,
891
+ domain,
892
+ sessionValid: true,
893
+ }, null, 2),
894
+ },
895
+ ],
896
+ };
897
+ }
898
+ // Session is expired - clear it and proceed with new login
899
+ logger.info(`[Auth] Existing session for ${domain} has expired (${validation.reason}). Proceeding with new login.`);
900
+ await this.authManager.clearSession(normalizedUrl);
901
+ }
902
+ try {
903
+ logger.info(`[Auth] Opening ${browser || 'auto-detected'} browser for authentication to ${domain}`);
904
+ // Perform interactive login
905
+ await this.authManager.performInteractiveLogin(normalizedUrl, {
906
+ browser,
907
+ loginUrl,
908
+ loginTimeoutSecs,
909
+ });
910
+ return {
911
+ content: [
912
+ {
913
+ type: 'text',
914
+ text: JSON.stringify({
915
+ status: 'success',
916
+ message: `Successfully authenticated to ${domain}. Session saved for future crawls.`,
917
+ domain,
918
+ instruction: 'You can now use add_documentation to crawl this site. The saved session will be used automatically.',
919
+ }, null, 2),
920
+ },
921
+ ],
922
+ };
923
+ }
924
+ catch (error) {
925
+ const safeErrorMessage = sanitizeErrorMessage(error);
926
+ logger.error(`[Auth] Authentication failed:`, safeErrorMessage);
927
+ return {
928
+ content: [
929
+ {
930
+ type: 'text',
931
+ text: JSON.stringify({
932
+ status: 'failed',
933
+ message: `Authentication failed: ${safeErrorMessage}`,
934
+ domain,
935
+ }, null, 2),
936
+ },
937
+ ],
938
+ };
939
+ }
940
+ }
941
+ /**
942
+ * Handle clearing saved authentication for a domain
943
+ */
944
+ async handleClearAuth(args) {
945
+ // Validate arguments with schema
946
+ let validatedArgs;
947
+ try {
948
+ validatedArgs = validateToolArgs(args, ClearAuthArgsSchema);
949
+ }
950
+ catch (error) {
951
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
952
+ }
953
+ const { url } = validatedArgs;
954
+ const normalizedUrl = normalizeUrl(url);
955
+ const domain = new URL(normalizedUrl).hostname;
956
+ await this.authManager.clearSession(normalizedUrl);
957
+ return {
958
+ content: [
959
+ {
960
+ type: 'text',
961
+ text: JSON.stringify({
962
+ status: 'success',
963
+ message: `Cleared saved authentication for ${domain}`,
964
+ domain,
965
+ }, null, 2),
966
+ },
967
+ ],
968
+ };
969
+ }
970
+ /**
971
+ * Handle deleting an indexed documentation site and all its data
972
+ */
973
+ async handleDeleteDocumentation(args) {
974
+ // Validate arguments with schema
975
+ let validatedArgs;
976
+ try {
977
+ validatedArgs = validateToolArgs(args, DeleteDocumentationArgsSchema);
978
+ }
979
+ catch (error) {
980
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
981
+ }
982
+ const { url, clearAuth = false } = validatedArgs;
983
+ const normalizedUrl = normalizeUrl(url);
984
+ const domain = new URL(normalizedUrl).hostname;
985
+ // Check if document exists
986
+ const doc = await this.store.getDocument(normalizedUrl);
987
+ if (!doc) {
988
+ return {
989
+ content: [
990
+ {
991
+ type: 'text',
992
+ text: JSON.stringify({
993
+ status: 'not_found',
994
+ message: `No indexed documentation found for ${normalizedUrl}`,
995
+ url: normalizedUrl,
996
+ }, null, 2),
997
+ },
998
+ ],
999
+ };
1000
+ }
1001
+ const deletedItems = [];
1002
+ try {
1003
+ // 1. Delete from SQLite and LanceDB (via store)
1004
+ await this.store.deleteDocument(normalizedUrl);
1005
+ deletedItems.push('document metadata (SQLite)', 'vector chunks (LanceDB)');
1006
+ logger.info(`[WebDocsServer] Deleted document from store: ${normalizedUrl}`);
1007
+ // 2. Delete both current and historical Crawlee datasets
1008
+ const datasetIds = [generateCrawlStorageId(normalizedUrl), generateDocId(normalizedUrl, domain)];
1009
+ const { Dataset } = await import('crawlee');
1010
+ const cleanupResults = await Promise.allSettled(datasetIds.map(async (datasetId) => {
1011
+ const dataset = await Dataset.open(datasetId);
1012
+ await dataset.drop();
1013
+ logger.info(`[WebDocsServer] Deleted Crawlee dataset: ${datasetId}`);
1014
+ }));
1015
+ if (cleanupResults.some((result) => result.status === 'fulfilled')) {
1016
+ deletedItems.push('crawl cache (Crawlee dataset)');
1017
+ }
1018
+ if (cleanupResults.some((result) => result.status === 'rejected')) {
1019
+ logger.debug(`[WebDocsServer] Some Crawlee datasets could not be deleted`);
1020
+ }
1021
+ // 3. Optionally clear auth session
1022
+ if (clearAuth) {
1023
+ await this.authManager.clearSession(normalizedUrl);
1024
+ deletedItems.push('authentication session');
1025
+ logger.info(`[WebDocsServer] Cleared auth session for ${domain}`);
1026
+ }
1027
+ // Optimize storage after deletion to reclaim space
1028
+ // This runs in the background and doesn't block the response
1029
+ this.store.optimize().catch((err) => {
1030
+ logger.warn('[WebDocsServer] Background optimization after delete failed:', err);
1031
+ });
1032
+ return {
1033
+ content: [
1034
+ {
1035
+ type: 'text',
1036
+ text: JSON.stringify({
1037
+ status: 'success',
1038
+ message: `Successfully deleted documentation for ${normalizedUrl}`,
1039
+ url: normalizedUrl,
1040
+ title: doc.title,
1041
+ deletedItems,
1042
+ }, null, 2),
1043
+ },
1044
+ ],
1045
+ };
1046
+ }
1047
+ catch (error) {
1048
+ const safeErrorMessage = sanitizeErrorMessage(error);
1049
+ logger.error(`[WebDocsServer] Error deleting documentation:`, safeErrorMessage);
1050
+ return {
1051
+ content: [
1052
+ {
1053
+ type: 'text',
1054
+ text: JSON.stringify({
1055
+ status: 'error',
1056
+ message: `Failed to delete documentation: ${safeErrorMessage}`,
1057
+ url: normalizedUrl,
1058
+ deletedItems,
1059
+ }, null, 2),
1060
+ },
1061
+ ],
1062
+ };
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Handle setting tags for a documentation site
1067
+ */
1068
+ async handleSetTags(args) {
1069
+ // Validate arguments with schema
1070
+ let validatedArgs;
1071
+ try {
1072
+ validatedArgs = validateToolArgs(args, SetTagsArgsSchema);
1073
+ }
1074
+ catch (error) {
1075
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1076
+ }
1077
+ const { url, tags } = validatedArgs;
1078
+ const normalizedUrl = normalizeUrl(url);
1079
+ try {
1080
+ await this.store.setTags(normalizedUrl, tags);
1081
+ // Get the updated document to return current state
1082
+ const doc = await this.store.getDocument(normalizedUrl);
1083
+ return {
1084
+ content: [
1085
+ {
1086
+ type: 'text',
1087
+ text: JSON.stringify({
1088
+ status: 'success',
1089
+ message: `Successfully updated tags for ${normalizedUrl}`,
1090
+ url: normalizedUrl,
1091
+ title: doc?.title,
1092
+ tags: doc?.tags || [],
1093
+ }, null, 2),
1094
+ },
1095
+ ],
1096
+ };
1097
+ }
1098
+ catch (error) {
1099
+ const safeErrorMessage = sanitizeErrorMessage(error);
1100
+ // Check for "Documentation not found" error
1101
+ if (safeErrorMessage.includes('Documentation not found')) {
1102
+ throw new McpError(ErrorCode.InvalidParams, `Documentation not found for URL: ${normalizedUrl}`);
1103
+ }
1104
+ throw new McpError(ErrorCode.InternalError, `Failed to set tags: ${safeErrorMessage}`);
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Handle listing all tags with usage counts
1109
+ */
1110
+ async handleListTags() {
1111
+ const tags = await this.store.listAllTags();
1112
+ return {
1113
+ content: [
1114
+ {
1115
+ type: 'text',
1116
+ text: JSON.stringify({
1117
+ tags,
1118
+ total: tags.length,
1119
+ }, null, 2),
1120
+ },
1121
+ ],
1122
+ };
1123
+ }
1124
+ // ============ Collection Handlers ============
1125
+ /**
1126
+ * Handle creating a new collection
1127
+ */
1128
+ async handleCreateCollection(args) {
1129
+ let validatedArgs;
1130
+ try {
1131
+ validatedArgs = validateToolArgs(args, CreateCollectionArgsSchema);
1132
+ }
1133
+ catch (error) {
1134
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1135
+ }
1136
+ const { name, description } = validatedArgs;
1137
+ try {
1138
+ await this.store.createCollection(name, description);
1139
+ return {
1140
+ content: [
1141
+ {
1142
+ type: 'text',
1143
+ text: JSON.stringify({
1144
+ status: 'success',
1145
+ message: `Collection "${name}" created successfully`,
1146
+ collection: {
1147
+ name,
1148
+ description,
1149
+ },
1150
+ }, null, 2),
1151
+ },
1152
+ ],
1153
+ };
1154
+ }
1155
+ catch (error) {
1156
+ const safeMessage = sanitizeErrorMessage(error);
1157
+ if (safeMessage.includes('already exists')) {
1158
+ throw new McpError(ErrorCode.InvalidParams, safeMessage);
1159
+ }
1160
+ throw new McpError(ErrorCode.InternalError, `Failed to create collection: ${safeMessage}`);
1161
+ }
1162
+ }
1163
+ /**
1164
+ * Handle deleting a collection
1165
+ */
1166
+ async handleDeleteCollection(args) {
1167
+ let validatedArgs;
1168
+ try {
1169
+ validatedArgs = validateToolArgs(args, DeleteCollectionArgsSchema);
1170
+ }
1171
+ catch (error) {
1172
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1173
+ }
1174
+ const { name } = validatedArgs;
1175
+ try {
1176
+ await this.store.deleteCollection(name);
1177
+ return {
1178
+ content: [
1179
+ {
1180
+ type: 'text',
1181
+ text: JSON.stringify({
1182
+ status: 'success',
1183
+ message: `Collection "${name}" deleted. Documentation sites remain indexed.`,
1184
+ }, null, 2),
1185
+ },
1186
+ ],
1187
+ };
1188
+ }
1189
+ catch (error) {
1190
+ const safeMessage = sanitizeErrorMessage(error);
1191
+ if (safeMessage.includes('not found')) {
1192
+ throw new McpError(ErrorCode.InvalidParams, safeMessage);
1193
+ }
1194
+ throw new McpError(ErrorCode.InternalError, `Failed to delete collection: ${safeMessage}`);
1195
+ }
1196
+ }
1197
+ /**
1198
+ * Handle updating a collection's metadata
1199
+ */
1200
+ async handleUpdateCollection(args) {
1201
+ let validatedArgs;
1202
+ try {
1203
+ validatedArgs = validateToolArgs(args, UpdateCollectionArgsSchema);
1204
+ }
1205
+ catch (error) {
1206
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1207
+ }
1208
+ const { name, newName, description } = validatedArgs;
1209
+ // Must provide at least one field to update
1210
+ if (newName === undefined && description === undefined) {
1211
+ throw new McpError(ErrorCode.InvalidParams, 'Must provide newName or description to update');
1212
+ }
1213
+ try {
1214
+ await this.store.updateCollection(name, { newName, description });
1215
+ return {
1216
+ content: [
1217
+ {
1218
+ type: 'text',
1219
+ text: JSON.stringify({
1220
+ status: 'success',
1221
+ message: `Collection updated successfully`,
1222
+ collection: {
1223
+ name: newName ?? name,
1224
+ description,
1225
+ },
1226
+ }, null, 2),
1227
+ },
1228
+ ],
1229
+ };
1230
+ }
1231
+ catch (error) {
1232
+ const safeMessage = sanitizeErrorMessage(error);
1233
+ if (safeMessage.includes('not found') || safeMessage.includes('already exists')) {
1234
+ throw new McpError(ErrorCode.InvalidParams, safeMessage);
1235
+ }
1236
+ throw new McpError(ErrorCode.InternalError, `Failed to update collection: ${safeMessage}`);
1237
+ }
1238
+ }
1239
+ /**
1240
+ * Handle listing all collections
1241
+ */
1242
+ async handleListCollections() {
1243
+ const collections = await this.store.listCollections();
1244
+ return {
1245
+ content: [
1246
+ {
1247
+ type: 'text',
1248
+ text: JSON.stringify({
1249
+ collections,
1250
+ total: collections.length,
1251
+ }, null, 2),
1252
+ },
1253
+ ],
1254
+ };
1255
+ }
1256
+ /**
1257
+ * Handle getting a specific collection with its documents
1258
+ */
1259
+ async handleGetCollection(args) {
1260
+ let validatedArgs;
1261
+ try {
1262
+ validatedArgs = validateToolArgs(args, GetCollectionArgsSchema);
1263
+ }
1264
+ catch (error) {
1265
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1266
+ }
1267
+ const { name } = validatedArgs;
1268
+ const collection = await this.store.getCollection(name);
1269
+ if (!collection) {
1270
+ throw new McpError(ErrorCode.InvalidParams, `Collection "${name}" not found`);
1271
+ }
1272
+ return {
1273
+ content: [
1274
+ {
1275
+ type: 'text',
1276
+ text: JSON.stringify(collection, null, 2),
1277
+ },
1278
+ ],
1279
+ };
1280
+ }
1281
+ /**
1282
+ * Handle adding documents to a collection
1283
+ */
1284
+ async handleAddToCollection(args) {
1285
+ let validatedArgs;
1286
+ try {
1287
+ validatedArgs = validateToolArgs(args, AddToCollectionArgsSchema);
1288
+ }
1289
+ catch (error) {
1290
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1291
+ }
1292
+ const { name, urls } = validatedArgs;
1293
+ // Normalize URLs
1294
+ const normalizedUrls = urls.map((url) => normalizeUrl(url));
1295
+ try {
1296
+ const result = await this.store.addToCollection(name, normalizedUrls);
1297
+ return {
1298
+ content: [
1299
+ {
1300
+ type: 'text',
1301
+ text: JSON.stringify({
1302
+ status: 'success',
1303
+ message: `Added ${result.added.length} document(s) to collection "${name}"`,
1304
+ ...result,
1305
+ }, null, 2),
1306
+ },
1307
+ ],
1308
+ };
1309
+ }
1310
+ catch (error) {
1311
+ const safeMessage = sanitizeErrorMessage(error);
1312
+ if (safeMessage.includes('not found')) {
1313
+ throw new McpError(ErrorCode.InvalidParams, safeMessage);
1314
+ }
1315
+ throw new McpError(ErrorCode.InternalError, `Failed to add to collection: ${safeMessage}`);
1316
+ }
1317
+ }
1318
+ /**
1319
+ * Handle removing documents from a collection
1320
+ */
1321
+ async handleRemoveFromCollection(args) {
1322
+ let validatedArgs;
1323
+ try {
1324
+ validatedArgs = validateToolArgs(args, RemoveFromCollectionArgsSchema);
1325
+ }
1326
+ catch (error) {
1327
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1328
+ }
1329
+ const { name, urls } = validatedArgs;
1330
+ // Normalize URLs
1331
+ const normalizedUrls = urls.map((url) => normalizeUrl(url));
1332
+ try {
1333
+ const result = await this.store.removeFromCollection(name, normalizedUrls);
1334
+ return {
1335
+ content: [
1336
+ {
1337
+ type: 'text',
1338
+ text: JSON.stringify({
1339
+ status: 'success',
1340
+ message: `Removed ${result.removed.length} document(s) from collection "${name}"`,
1341
+ ...result,
1342
+ }, null, 2),
1343
+ },
1344
+ ],
1345
+ };
1346
+ }
1347
+ catch (error) {
1348
+ const safeMessage = sanitizeErrorMessage(error);
1349
+ if (safeMessage.includes('not found')) {
1350
+ throw new McpError(ErrorCode.InvalidParams, safeMessage);
1351
+ }
1352
+ throw new McpError(ErrorCode.InternalError, `Failed to remove from collection: ${safeMessage}`);
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Handle searching within a collection
1357
+ */
1358
+ async handleSearchCollection(args) {
1359
+ let validatedArgs;
1360
+ try {
1361
+ validatedArgs = validateToolArgs(args, SearchCollectionArgsSchema);
1362
+ }
1363
+ catch (error) {
1364
+ throw new McpError(ErrorCode.InvalidParams, sanitizeErrorMessage(error));
1365
+ }
1366
+ const { name, query, limit = 10 } = validatedArgs;
1367
+ // Get URLs in the collection
1368
+ const collectionUrls = await this.store.getCollectionUrls(name);
1369
+ if (collectionUrls.length === 0) {
1370
+ // Check if collection exists but is empty
1371
+ const collection = await this.store.getCollection(name);
1372
+ if (!collection) {
1373
+ throw new McpError(ErrorCode.InvalidParams, `Collection "${name}" not found`);
1374
+ }
1375
+ return {
1376
+ content: [
1377
+ {
1378
+ type: 'text',
1379
+ text: JSON.stringify({
1380
+ results: [],
1381
+ message: `Collection "${name}" is empty. Add documentation sites to search.`,
1382
+ }, null, 2),
1383
+ },
1384
+ ],
1385
+ };
1386
+ }
1387
+ const collectionResults = await this.store.searchByText(query, { limit, filterUrls: collectionUrls });
1388
+ // Apply prompt injection detection and filter/process results (same as handleSearchDocumentation)
1389
+ let blockedCount = 0;
1390
+ const safeResults = collectionResults
1391
+ .map((result) => {
1392
+ const injectionResult = detectPromptInjection(result.content);
1393
+ if (injectionResult.maxSeverity === 'high') {
1394
+ blockedCount++;
1395
+ logger.debug(`[Security] Blocked search result from ${result.url} due to high-severity injection pattern: ${injectionResult.detections[0]?.description}`);
1396
+ return null;
1397
+ }
1398
+ let safeContent = addInjectionWarnings(result.content, injectionResult);
1399
+ safeContent = wrapExternalContent(safeContent, result.url);
1400
+ return {
1401
+ ...result,
1402
+ content: safeContent,
1403
+ security: {
1404
+ isExternalContent: true,
1405
+ injectionDetected: injectionResult.hasInjection,
1406
+ injectionSeverity: injectionResult.maxSeverity,
1407
+ detectionCount: injectionResult.detections.length,
1408
+ },
1409
+ };
1410
+ })
1411
+ .filter((result) => result !== null);
1412
+ const response = {
1413
+ results: safeResults,
1414
+ collection: name,
1415
+ };
1416
+ if (blockedCount > 0) {
1417
+ response.securityNotice = `${blockedCount} result(s) were blocked due to high-severity prompt injection patterns.`;
1418
+ }
1419
+ return {
1420
+ content: [
1421
+ {
1422
+ type: 'text',
1423
+ text: JSON.stringify(response, null, 2),
1424
+ },
1425
+ ],
1426
+ };
1427
+ }
1428
+ run() {
1429
+ return (this.runPromise ??= this.start());
1430
+ }
1431
+ async start() {
1432
+ await this.initialize();
1433
+ if (this.closePromise) {
1434
+ return;
1435
+ }
1436
+ await this.server.connect(new StdioServerTransport());
1437
+ logger.info('Web Docs MCP server running on stdio');
1438
+ }
1439
+ close() {
1440
+ return (this.closePromise ??= this.closeResources());
1441
+ }
1442
+ async closeResources() {
1443
+ await this.runPromise?.catch(() => undefined);
1444
+ const serverClose = Promise.resolve().then(() => this.server.close());
1445
+ const activeToolDrain = serverClose.catch(() => undefined).then(() => Promise.allSettled([...this.activeToolCalls]));
1446
+ const indexingDrain = Promise.resolve().then(() => this.indexingQueue.cancelAll());
1447
+ const concurrentCleanup = Promise.allSettled([
1448
+ Promise.resolve().then(() => this.statusTracker.stop()),
1449
+ Promise.resolve().then(() => this.authManager?.cleanup()),
1450
+ Promise.resolve().then(() => closeOutboundProxy()),
1451
+ ]);
1452
+ const results = await Promise.allSettled([serverClose, activeToolDrain, indexingDrain]);
1453
+ results.push(...(await Promise.allSettled([Promise.resolve().then(() => this.store?.close())])), ...(await concurrentCleanup));
1454
+ const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : []));
1455
+ if (errors.length > 0) {
1456
+ throw new AggregateError(errors, 'Failed to shut down cleanly');
1457
+ }
1458
+ }
1459
+ }
1460
+ //# sourceMappingURL=server.js.map