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