@cosmocoder/mcp-web-docs 2.0.21 → 2.0.23

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