@afterxleep/doc-bot 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ doc-bot is an intelligent documentation server that:
13
13
  - 🧠 **Auto-indexes** content for smart inference, based on metadata and keywords
14
14
  - 🤖 **Provides agentic tools** to query, and update your documentation
15
15
  - 🔄 **Updates** automatically when docs change
16
+ - 📚 **Supports Docsets** for searching official API documentation alongside your custom docs
16
17
 
17
18
  ## Why MCP Instead of Static Rules?
18
19
 
@@ -112,6 +113,18 @@ IDE's use static rule files (like Cursor Rules or Copilot's .github/copilot-inst
112
113
  }
113
114
  }
114
115
  ```
116
+
117
+ **With Docsets support (for official API documentation):**
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "doc-bot": {
122
+ "command": "npx",
123
+ "args": ["@afterxleep/doc-bot@latest", "--docs", "./docs", "--docsets", "~/MyDocSets"]
124
+ }
125
+ }
126
+ }
127
+ ```
115
128
  Note: If a relative path does not work, you can use VSCode `${workspaceFolder}`environment variable `${workspaceFolder}/my-custom-docs`
116
129
 
117
130
 
@@ -386,6 +399,23 @@ git push origin main
386
399
  git push --tags # Push version tags
387
400
  ```
388
401
 
402
+ ## Docset Support
403
+
404
+ doc-bot now supports [Docsets](https://kapeli.com/docsets) - pre-indexed documentation used by Dash, Zeal, and Velocity. This allows you to search official API documentation alongside your custom project docs.
405
+
406
+ ### Key Features
407
+ - Search iOS, macOS, Swift, and other official documentation
408
+ - Install docsets from URLs or local files
409
+ - Unified search across both custom docs and official APIs
410
+ - No manual HTML parsing required
411
+
412
+ ### Quick Start
413
+ 1. **Install a docset**: Use the `add_docset` tool with a URL or local path
414
+ 2. **Search documentation**: Use `search_docsets` for docsets only, or `search_all` for unified results
415
+ 3. **Manage docsets**: List installed docsets with `list_docsets`, remove with `remove_docset`
416
+
417
+ See [DOCSETS.md](./DOCSETS.md) for detailed documentation.
418
+
389
419
  ## License
390
420
 
391
421
  MIT License - see the [LICENSE](LICENSE) file for details.
package/bin/doc-bot.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { program } from 'commander';
4
4
  import path from 'path';
5
+ import os from 'os';
5
6
  import fs from 'fs-extra';
6
7
  import { DocsServer } from '../src/index.js';
7
8
  import { readFileSync } from 'fs';
@@ -17,6 +18,7 @@ program
17
18
  .description('Generic MCP server for intelligent documentation access')
18
19
  .version(packageJson.version)
19
20
  .option('-d, --docs <path>', 'Path to docs folder', 'doc-bot')
21
+ .option('--docsets <path>', 'Path to docsets folder', path.join(os.homedir(), 'Developer', 'DocSets'))
20
22
  .option('-v, --verbose', 'Enable verbose logging')
21
23
  .option('-w, --watch', 'Watch for file changes')
22
24
  .parse();
@@ -25,6 +27,7 @@ const options = program.opts();
25
27
 
26
28
  async function main() {
27
29
  const docsPath = path.resolve(options.docs);
30
+ const docsetsPath = path.resolve(options.docsets);
28
31
 
29
32
  // Check if documentation folder exists
30
33
  if (!await fs.pathExists(docsPath)) {
@@ -46,6 +49,7 @@ async function main() {
46
49
 
47
50
  const server = new DocsServer({
48
51
  docsPath,
52
+ docsetsPath,
49
53
  verbose: options.verbose,
50
54
  watch: options.watch
51
55
  });
@@ -53,6 +57,7 @@ async function main() {
53
57
  if (options.verbose) {
54
58
  console.error('🚀 Starting doc-bot...');
55
59
  console.error(`📁 Documentation: ${docsPath}`);
60
+ console.error(`📚 Docsets: ${docsetsPath}`);
56
61
  console.error(`⚙️ Configuration: Frontmatter-based`);
57
62
 
58
63
  if (options.watch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afterxleep/doc-bot",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Generic MCP server for intelligent documentation access in any project",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -45,7 +45,12 @@
45
45
  "chokidar": "^3.5.3",
46
46
  "fs-extra": "^11.0.0",
47
47
  "glob": "^10.3.0",
48
- "yaml": "^2.3.0"
48
+ "yaml": "^2.3.0",
49
+ "better-sqlite3": "^11.2.1",
50
+ "axios": "^1.6.5",
51
+ "tar": "^6.2.0",
52
+ "plist": "^3.1.0",
53
+ "adm-zip": "^0.5.10"
49
54
  },
50
55
  "devDependencies": {
51
56
  "eslint": "^8.57.0",
@@ -0,0 +1,146 @@
1
+ import { DocsServer } from '../index.js';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname } from 'path';
6
+ import os from 'os';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ describe('DocsServer Docset Integration', () => {
12
+ let server;
13
+ let tempDocsPath;
14
+ let tempDocsetsPath;
15
+
16
+ beforeEach(async () => {
17
+ // Create temporary directories
18
+ tempDocsPath = path.join(__dirname, 'temp-docs-' + Date.now());
19
+ tempDocsetsPath = path.join(__dirname, 'temp-docsets-' + Date.now());
20
+
21
+ await fs.ensureDir(tempDocsPath);
22
+ await fs.ensureDir(tempDocsetsPath);
23
+
24
+ // Create a simple test doc
25
+ await fs.writeFile(
26
+ path.join(tempDocsPath, 'test.md'),
27
+ '---\nalwaysApply: true\ntitle: Test Doc\n---\n# Test'
28
+ );
29
+ });
30
+
31
+ afterEach(async () => {
32
+ if (server) {
33
+ await server.stop();
34
+ }
35
+ await fs.remove(tempDocsPath);
36
+ await fs.remove(tempDocsetsPath);
37
+ });
38
+
39
+ describe('Server initialization with docsets', () => {
40
+ it('should initialize with custom docsets path', async () => {
41
+ server = new DocsServer({
42
+ docsPath: tempDocsPath,
43
+ docsetsPath: tempDocsetsPath,
44
+ verbose: false
45
+ });
46
+
47
+ await server.start();
48
+
49
+ expect(server.docsetService).toBeDefined();
50
+ expect(server.docsetService.storagePath).toBe(tempDocsetsPath);
51
+ });
52
+
53
+ it('should use default docsets path when not provided', async () => {
54
+ server = new DocsServer({
55
+ docsPath: tempDocsPath,
56
+ verbose: false
57
+ });
58
+
59
+ await server.start();
60
+
61
+ const expectedPath = path.join(os.homedir(), 'Developer', 'DocSets');
62
+ expect(server.docsetService.storagePath).toBe(expectedPath);
63
+ });
64
+ });
65
+
66
+ describe('Docset service functionality', () => {
67
+ beforeEach(async () => {
68
+ server = new DocsServer({
69
+ docsPath: tempDocsPath,
70
+ docsetsPath: tempDocsetsPath,
71
+ verbose: false
72
+ });
73
+ await server.start();
74
+ });
75
+
76
+ it('should have docset service initialized', () => {
77
+ expect(server.docsetService).toBeDefined();
78
+ expect(server.docsetDatabase).toBeDefined();
79
+ });
80
+
81
+ it('should have empty docsets initially', async () => {
82
+ const docsets = await server.docsetService.listDocsets();
83
+ expect(docsets).toEqual([]);
84
+ });
85
+
86
+ it('should handle docset operations', async () => {
87
+ // Create a mock docset
88
+ const mockDocsetPath = path.join(tempDocsetsPath, 'Mock.docset');
89
+ const contentsPath = path.join(mockDocsetPath, 'Contents');
90
+ const resourcesPath = path.join(contentsPath, 'Resources');
91
+
92
+ await fs.ensureDir(resourcesPath);
93
+
94
+ // Create Info.plist
95
+ const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
96
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
97
+ <plist version="1.0">
98
+ <dict>
99
+ <key>CFBundleName</key>
100
+ <string>Mock Documentation</string>
101
+ <key>CFBundleIdentifier</key>
102
+ <string>mock.documentation</string>
103
+ </dict>
104
+ </plist>`;
105
+ await fs.writeFile(path.join(contentsPath, 'Info.plist'), infoPlist);
106
+
107
+ // Create SQLite database
108
+ const Database = (await import('better-sqlite3')).default;
109
+ const dbPath = path.join(resourcesPath, 'docSet.dsidx');
110
+ const db = new Database(dbPath);
111
+ db.exec(`
112
+ CREATE TABLE searchIndex(
113
+ id INTEGER PRIMARY KEY,
114
+ name TEXT,
115
+ type TEXT,
116
+ path TEXT
117
+ );
118
+ `);
119
+ db.prepare('INSERT INTO searchIndex (name, type, path) VALUES (?, ?, ?)')
120
+ .run('TestClass', 'Class', 'test.html');
121
+ db.close();
122
+
123
+ // Test adding docset
124
+ const docsetInfo = await server.docsetService.addDocset(mockDocsetPath);
125
+ expect(docsetInfo.name).toBe('Mock Documentation');
126
+
127
+ // Test listing docsets
128
+ const docsets = await server.docsetService.listDocsets();
129
+ expect(docsets).toHaveLength(1);
130
+ expect(docsets[0].name).toBe('Mock Documentation');
131
+
132
+ // Add to database for searching
133
+ server.docsetDatabase.addDocset(docsetInfo);
134
+
135
+ // Test searching
136
+ const searchResults = server.docsetDatabase.search('Test');
137
+ expect(searchResults).toHaveLength(1);
138
+ expect(searchResults[0].name).toBe('TestClass');
139
+
140
+ // Test removing docset
141
+ await server.docsetService.removeDocset(docsetInfo.id);
142
+ const finalDocsets = await server.docsetService.listDocsets();
143
+ expect(finalDocsets).toHaveLength(0);
144
+ });
145
+ });
146
+ });
package/src/index.js CHANGED
@@ -3,8 +3,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { DocumentationService } from './services/DocumentationService.js';
5
5
  import { InferenceEngine } from './services/InferenceEngine.js';
6
+ import { DocsetService } from './services/docset/index.js';
7
+ import { MultiDocsetDatabase } from './services/docset/database.js';
6
8
  import chokidar from 'chokidar';
7
9
  import path from 'path';
10
+ import os from 'os';
8
11
  import { promises as fs } from 'fs';
9
12
  import { fileURLToPath } from 'url';
10
13
  import { dirname } from 'path';
@@ -16,6 +19,7 @@ class DocsServer {
16
19
  constructor(options = {}) {
17
20
  this.options = {
18
21
  docsPath: options.docsPath || './doc-bot',
22
+ docsetsPath: options.docsetsPath || path.join(os.homedir(), 'Developer', 'DocSets'),
19
23
  verbose: options.verbose || false,
20
24
  watch: options.watch || false,
21
25
  ...options
@@ -38,6 +42,10 @@ class DocsServer {
38
42
  this.docService = new DocumentationService(this.options.docsPath);
39
43
  this.inferenceEngine = new InferenceEngine(this.docService);
40
44
 
45
+ // Initialize docset services
46
+ this.docsetService = new DocsetService(this.options.docsetsPath);
47
+ this.docsetDatabase = new MultiDocsetDatabase();
48
+
41
49
  this.setupHandlers();
42
50
 
43
51
  if (this.options.watch) {
@@ -252,6 +260,95 @@ class DocsServer {
252
260
  properties: {},
253
261
  additionalProperties: false
254
262
  }
263
+ },
264
+ // Docset tools
265
+ {
266
+ name: 'add_docset',
267
+ description: 'Add a new documentation set from a URL or local file path. Supports .docset directories, .tgz/.tar.gz/.zip archives.',
268
+ inputSchema: {
269
+ type: 'object',
270
+ properties: {
271
+ source: {
272
+ type: 'string',
273
+ description: 'URL to download docset from OR local file path. Example: https://example.com/iOS.tgz or /path/to/Swift.docset'
274
+ }
275
+ },
276
+ required: ['source']
277
+ }
278
+ },
279
+ {
280
+ name: 'remove_docset',
281
+ description: 'Remove an installed documentation set',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ docsetId: {
286
+ type: 'string',
287
+ description: 'ID of the docset to remove'
288
+ }
289
+ },
290
+ required: ['docsetId']
291
+ }
292
+ },
293
+ {
294
+ name: 'list_docsets',
295
+ description: 'List all installed documentation sets',
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: {}
299
+ }
300
+ },
301
+ {
302
+ name: 'search_docsets',
303
+ description: 'Search installed documentation sets for API references, classes, methods, and guides. Returns official documentation with direct file links.',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ query: {
308
+ type: 'string',
309
+ description: 'Search query - can be class names, method names, concepts, or any technical term'
310
+ },
311
+ type: {
312
+ type: 'string',
313
+ description: 'Optional: filter by type (Class, Method, Function, Guide, Property, Protocol, Enum, etc.)',
314
+ enum: ['Class', 'Method', 'Function', 'Property', 'Protocol', 'Enum', 'Structure', 'Guide', 'Sample', 'Category', 'Constant', 'Variable', 'Typedef', 'Macro']
315
+ },
316
+ docsetId: {
317
+ type: 'string',
318
+ description: 'Optional: limit search to specific docset ID'
319
+ },
320
+ limit: {
321
+ type: 'number',
322
+ description: 'Maximum results to return (default: 50)',
323
+ minimum: 1,
324
+ maximum: 200,
325
+ default: 50
326
+ }
327
+ },
328
+ required: ['query']
329
+ }
330
+ },
331
+ {
332
+ name: 'docset_stats',
333
+ description: 'Get detailed statistics about installed documentation sets',
334
+ inputSchema: {
335
+ type: 'object',
336
+ properties: {}
337
+ }
338
+ },
339
+ {
340
+ name: 'search_all',
341
+ description: 'Search both Markdown documentation and installed docsets. Provides unified results from all sources.',
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: {
345
+ query: {
346
+ type: 'string',
347
+ description: 'Search query to find in both documentation types'
348
+ }
349
+ },
350
+ required: ['query']
351
+ }
255
352
  }
256
353
  ]
257
354
  };
@@ -368,6 +465,134 @@ class DocsServer {
368
465
  }]
369
466
  };
370
467
 
468
+ // Docset tools
469
+ case 'add_docset':
470
+ const { source } = args || {};
471
+ if (!source) {
472
+ throw new Error('source parameter is required');
473
+ }
474
+
475
+ try {
476
+ const docsetInfo = await this.docsetService.addDocset(source);
477
+ this.docsetDatabase.addDocset(docsetInfo);
478
+
479
+ return {
480
+ content: [{
481
+ type: 'text',
482
+ text: JSON.stringify({
483
+ success: true,
484
+ docset: docsetInfo,
485
+ message: `Successfully added docset: ${docsetInfo.name}`
486
+ }, null, 2)
487
+ }]
488
+ };
489
+ } catch (error) {
490
+ return {
491
+ content: [{
492
+ type: 'text',
493
+ text: JSON.stringify({
494
+ success: false,
495
+ error: error.message
496
+ }, null, 2)
497
+ }]
498
+ };
499
+ }
500
+
501
+ case 'remove_docset':
502
+ const { docsetId } = args || {};
503
+ if (!docsetId) {
504
+ throw new Error('docsetId parameter is required');
505
+ }
506
+
507
+ try {
508
+ await this.docsetService.removeDocset(docsetId);
509
+ this.docsetDatabase.removeDocset(docsetId);
510
+
511
+ return {
512
+ content: [{
513
+ type: 'text',
514
+ text: JSON.stringify({
515
+ success: true,
516
+ message: `Successfully removed docset: ${docsetId}`
517
+ }, null, 2)
518
+ }]
519
+ };
520
+ } catch (error) {
521
+ return {
522
+ content: [{
523
+ type: 'text',
524
+ text: JSON.stringify({
525
+ success: false,
526
+ error: error.message
527
+ }, null, 2)
528
+ }]
529
+ };
530
+ }
531
+
532
+ case 'list_docsets':
533
+ const docsets = await this.docsetService.listDocsets();
534
+
535
+ return {
536
+ content: [{
537
+ type: 'text',
538
+ text: JSON.stringify(docsets, null, 2)
539
+ }]
540
+ };
541
+
542
+ case 'search_docsets':
543
+ const { query: docsetQuery, type, docsetId: searchDocsetId, limit } = args || {};
544
+ if (!docsetQuery) {
545
+ throw new Error('query parameter is required');
546
+ }
547
+
548
+ const docsetResults = this.docsetDatabase.search(docsetQuery, { type, docsetId: searchDocsetId, limit });
549
+
550
+ return {
551
+ content: [{
552
+ type: 'text',
553
+ text: JSON.stringify({
554
+ query: docsetQuery,
555
+ resultCount: docsetResults.length,
556
+ results: docsetResults
557
+ }, null, 2)
558
+ }]
559
+ };
560
+
561
+ case 'docset_stats':
562
+ const stats = this.docsetDatabase.getStats();
563
+
564
+ return {
565
+ content: [{
566
+ type: 'text',
567
+ text: JSON.stringify(stats, null, 2)
568
+ }]
569
+ };
570
+
571
+ case 'search_all':
572
+ const { query: allQuery } = args || {};
573
+ if (!allQuery) {
574
+ throw new Error('query parameter is required');
575
+ }
576
+
577
+ // Search markdown documentation
578
+ const markdownResults = await this.docService.searchDocuments(allQuery);
579
+
580
+ // Search docsets
581
+ const allDocsetResults = this.docsetDatabase.search(allQuery, { limit: 50 });
582
+
583
+ return {
584
+ content: [{
585
+ type: 'text',
586
+ text: JSON.stringify({
587
+ query: allQuery,
588
+ sources: ['markdown', 'docsets'],
589
+ markdownResults: markdownResults.slice(0, 10),
590
+ docsetResults: allDocsetResults,
591
+ totalResults: markdownResults.length + allDocsetResults.length
592
+ }, null, 2)
593
+ }]
594
+ };
595
+
371
596
  default:
372
597
  throw new Error(`Unknown tool: ${name}`);
373
598
  }
@@ -839,6 +1064,19 @@ class DocsServer {
839
1064
  await this.docService.initialize();
840
1065
  await this.inferenceEngine.initialize();
841
1066
 
1067
+ // Initialize docset service
1068
+ await this.docsetService.initialize();
1069
+
1070
+ // Load existing docsets into the database
1071
+ const existingDocsets = await this.docsetService.listDocsets();
1072
+ for (const docset of existingDocsets) {
1073
+ this.docsetDatabase.addDocset(docset);
1074
+ }
1075
+
1076
+ if (this.options.verbose && existingDocsets.length > 0) {
1077
+ console.error(`📚 Loaded ${existingDocsets.length} docsets from ${this.docsetService.storagePath}`);
1078
+ }
1079
+
842
1080
  // Start server
843
1081
  const transport = new StdioServerTransport();
844
1082
  await this.server.connect(transport);
@@ -848,6 +1086,13 @@ class DocsServer {
848
1086
  console.error('🚀 Using frontmatter-based configuration');
849
1087
  }
850
1088
  }
1089
+
1090
+ async stop() {
1091
+ if (this.docsetDatabase) {
1092
+ this.docsetDatabase.closeAll();
1093
+ }
1094
+ await this.server.close();
1095
+ }
851
1096
  }
852
1097
 
853
1098
  export { DocsServer };