├── docs-mcp.config.json
├── setup.sh
├── Dockerfile
├── smithery.yaml
├── bin
└── mcp
├── package.json
├── LICENSE
├── test-docs
└── sample.md
├── .github
└── workflows
│ ├── publish-npm.yml
│ └── release-mcp.yml
├── test.js
├── .gitignore
├── src
├── config.js
└── index.js
├── scripts
└── build.js
└── README.md
/docs-mcp.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "includeDir": "./site",
3 | "toolName": "search_docs",
4 | "toolDescription": "Search documentation using the probe search engine.",
5 | "ignorePatterns": [
6 | "node_modules",
7 | ".git",
8 | "dist",
9 | "build",
10 | "coverage",
11 | ".vitepress/cache"
12 | ]
13 | }
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Setup script for docs-mcp example
3 |
4 | # Change to the example directory
5 | cd "$(dirname "$0")"
6 |
7 | # Install dependencies
8 | echo "Installing dependencies..."
9 | npm install
10 |
11 | # Run the build script
12 | echo "Building the docs-mcp example..."
13 | node scripts/build.js
14 |
15 | # Make the executable script executable
16 | chmod +x bin/probe-docs-mcp
17 |
18 | echo "Setup completed successfully!"
19 | echo "You can now run the example with: ./bin/probe-docs-mcp"
20 | echo "Or test it with: node test.js"
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | FROM node:lts-alpine
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Copy package files
8 | COPY package*.json ./
9 |
10 | # Install dependencies without running lifecycle scripts
11 | RUN npm install --ignore-scripts
12 |
13 | # Copy all source files
14 | COPY . .
15 |
16 | # Run build command
17 | RUN npm run build
18 |
19 | # Expose port if needed (not required for stdio transport, but added for future use)
20 | EXPOSE 3000
21 |
22 | # Start the MCP server
23 | CMD [ "node", "src/index.js" ]
24 |
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | type: object
8 | properties: {}
9 | description: No configuration required for docs-mcp. All options are set via
10 | environment variables or config file.
11 | commandFunction:
12 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
13 | |-
14 | (config) => ({ command: 'node', args: ['src/index.js'] })
15 | exampleConfig: {}
16 |
--------------------------------------------------------------------------------
/bin/mcp:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { fileURLToPath } from 'url';
3 | import { dirname, resolve } from 'path';
4 | import { spawn } from 'child_process';
5 |
6 | // Get the directory of the current module
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = dirname(__filename);
9 |
10 | // Path to the main script
11 | const mainScript = resolve(__dirname, '..', 'src', 'index.js');
12 |
13 | // Execute the main script with the current process arguments
14 | const args = process.argv.slice(2);
15 | const child = spawn(process.execPath, [mainScript, ...args], {
16 | stdio: 'inherit'
17 | });
18 |
19 | // Handle process exit
20 | child.on('exit', (code) => {
21 | process.exit(code);
22 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@probelabs/docs-mcp",
3 | "version": "0.1.0",
4 | "description": "Using probe MCP for documentation search",
5 | "type": "module",
6 | "main": "src/index.js",
7 | "bin": {
8 | "mcp": "bin/mcp"
9 | },
10 | "files": [
11 | "bin",
12 | "src",
13 | "data",
14 | "docs-mcp.config.json"
15 | ],
16 | "scripts": {
17 | "build": "node scripts/build.js",
18 | "start": "node src/index.js"
19 | },
20 | "keywords": [
21 | "probe",
22 | "mcp",
23 | "documentation",
24 | "search"
25 | ],
26 | "author": "Leonid Bugaev",
27 | "license": "MIT",
28 | "dependencies": {
29 | "@probelabs/probe": "^0.6.0-rc128",
30 | "@modelcontextprotocol/sdk": "^1.6.0",
31 | "axios": "^1.7.2",
32 | "fs-extra": "^11.1.1",
33 | "glob": "^10.3.10",
34 | "minimist": "^1.2.8",
35 | "simple-git": "^3.22.0",
36 | "tar": "^7.1.0"
37 | }
38 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Leonid Bugaev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test-docs/sample.md:
--------------------------------------------------------------------------------
1 | # Sample Documentation
2 |
3 | This is a sample documentation file to test the Probe Docs MCP example.
4 |
5 | ## Installation
6 |
7 | To install the package, run:
8 |
9 | ```bash
10 | npm install @probelabs/probe
11 | ```
12 |
13 | ## Usage
14 |
15 | Here's how to use the package:
16 |
17 | ```javascript
18 | import { search } from '@probelabs/probe';
19 |
20 | const results = await search({
21 | path: '/path/to/your/code',
22 | query: 'function example'
23 | });
24 |
25 | console.log(results);
26 | ```
27 |
28 | ## Configuration
29 |
30 | You can configure the search with various options:
31 |
32 | - `path`: The path to search in
33 | - `query`: The search query
34 | - `filesOnly`: Skip AST parsing and just output unique files
35 | - `maxResults`: Maximum number of results to return
36 | - `maxTokens`: Maximum tokens to return
37 |
38 | ## Advanced Features
39 |
40 | ### Custom Patterns
41 |
42 | You can use custom patterns to search for specific code structures:
43 |
44 | ```javascript
45 | import { query } from '@probelabs/probe';
46 |
47 | const results = await query({
48 | path: '/path/to/your/code',
49 | pattern: 'function $NAME($$$PARAMS) $$$BODY'
50 | });
51 |
52 | console.log(results);
53 | ```
54 |
55 | ### Extracting Code
56 |
57 | You can extract code from specific files:
58 |
59 | ```javascript
60 | import { extract } from '@probelabs/probe';
61 |
62 | const results = await extract({
63 | path: '/path/to/your/code',
64 | files: ['/path/to/your/code/file.js:10-20']
65 | });
66 |
67 | console.log(results);
--------------------------------------------------------------------------------
/.github/workflows/publish-npm.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docs MCP to npm
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*" # Trigger on tags like v1.0.0, v1.1.0, etc.
7 |
8 | permissions:
9 | contents: read # Needed to check out the repository
10 |
11 | jobs:
12 | publish-npm:
13 | name: Publish @probelabs/docs-mcp to npm
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Extract version from tag
20 | id: extract_version
21 | run: |
22 | # Remove 'v' prefix from tag (e.g., v1.0.0 -> 1.0.0)
23 | VERSION="${GITHUB_REF#refs/tags/v}"
24 | echo "VERSION=$VERSION" >> $GITHUB_ENV
25 | echo "Extracted version: $VERSION"
26 |
27 | - name: Setup Node.js
28 | uses: actions/setup-node@v4
29 | with:
30 | node-version: "18"
31 | registry-url: "https://registry.npmjs.org/"
32 | scope: "@probelabs"
33 |
34 | - name: Install dependencies
35 | run: npm install
36 |
37 | - name: Update package version
38 | run: |
39 | # Update version in package.json without creating a git tag
40 | npm version ${{ env.VERSION }} --no-git-tag-version
41 | echo "Updated package.json to version ${{ env.VERSION }}"
42 |
43 | - name: Build package
44 | run: npm run build # Ensure the data directory is prepared
45 |
46 | - name: Publish to npm
47 | run: npm publish --access public
48 | env:
49 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
--------------------------------------------------------------------------------
/test.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { spawn } from 'child_process';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 |
6 | // Get the directory of the current module
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 |
10 | // Path to the MCP server script
11 | const mcpServerPath = path.join(__dirname, 'bin', 'probe-docs-mcp');
12 |
13 | // Sample MCP request
14 | const mcpRequest = {
15 | jsonrpc: '2.0',
16 | id: '1',
17 | method: 'mcp.callTool',
18 | params: {
19 | name: 'search_docs',
20 | arguments: {
21 | query: 'installation',
22 | maxResults: 5,
23 | maxTokens: 2000
24 | }
25 | }
26 | };
27 |
28 | // Start the MCP server
29 | console.log('Starting MCP server...');
30 | const mcpServer = spawn(mcpServerPath, [], {
31 | stdio: ['pipe', 'pipe', process.stderr]
32 | });
33 |
34 | // Handle server output
35 | let buffer = '';
36 | mcpServer.stdout.on('data', (data) => {
37 | buffer += data.toString();
38 |
39 | // Check if we have a complete JSON response
40 | try {
41 | const lines = buffer.split('\n');
42 | for (let i = 0; i < lines.length; i++) {
43 | const line = lines[i].trim();
44 | if (line && line.startsWith('{') && line.endsWith('}')) {
45 | const response = JSON.parse(line);
46 | console.log('\nReceived response:');
47 | console.log(JSON.stringify(response, null, 2));
48 |
49 | // Exit after receiving the response
50 | console.log('\nTest completed successfully!');
51 | mcpServer.kill();
52 | process.exit(0);
53 | }
54 | }
55 | } catch (error) {
56 | // Not a complete JSON response yet, continue buffering
57 | }
58 | });
59 |
60 | // Send the request after a short delay to allow the server to start
61 | setTimeout(() => {
62 | console.log('\nSending request:');
63 | console.log(JSON.stringify(mcpRequest, null, 2));
64 | mcpServer.stdin.write(JSON.stringify(mcpRequest) + '\n');
65 | }, 1000);
66 |
67 | // Handle server exit
68 | mcpServer.on('exit', (code) => {
69 | if (code !== 0) {
70 | console.error(`MCP server exited with code ${code}`);
71 | process.exit(code);
72 | }
73 | });
74 |
75 | // Handle process termination
76 | process.on('SIGINT', () => {
77 | mcpServer.kill();
78 | process.exit(0);
79 | });
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | data
2 | # Logs
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | pnpm-debug.log*
9 | lerna-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
31 | .grunt
32 |
33 | # Bower dependency directory (https://bower.io/)
34 | bower_components
35 |
36 | # node-waf configuration
37 | .lock-wscript
38 |
39 | # Compiled binary addons (https://nodejs.org/api/addons.html)
40 | build/Release
41 |
42 | # Dependency directories
43 | node_modules/
44 | jspm_packages/
45 |
46 | # Snowpack dependency directory (https://snowpack.dev/)
47 | web_modules/
48 |
49 | # TypeScript cache
50 | *.tsbuildinfo
51 |
52 | # Optional npm cache directory
53 | .npm
54 |
55 | # Optional eslint cache
56 | .eslintcache
57 |
58 | # Optional stylelint cache
59 | .stylelintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 | .env.development.local
79 | .env.test.local
80 | .env.production.local
81 | .env.local
82 |
83 | # parcel-bundler cache files
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 | out
90 |
91 | # Nuxt.js build output
92 | .nuxt
93 | dist
94 |
95 | # Remix build output
96 | .cache/
97 | build/
98 | public/build/
99 |
100 | # Docusaurus cache and build output
101 | .docusaurus
102 |
103 | # Gatsby cache and build output
104 | .cache/
105 | public
106 |
107 | # SvelteKit build output
108 | .svelte-kit
109 |
110 | # Strapi build output
111 | build/
112 |
113 | # Temporary files created by the build script if cloning fails (adjust if needed)
114 | data/.git_clone_failed
115 |
116 | # macOS specific files
117 | .DS_Store
118 | .AppleDouble
119 | .LSOverride
120 |
121 | # Thumbnails
122 | Thumbs.db
123 | ehthumbs.db
124 | ehthumbs_vista.db
125 |
126 | # Folder config file
127 | Desktop.ini
128 |
129 | # Recycle Bin used on file shares
130 | $RECYCLE.BIN/
131 |
132 | # Windows Installer files
133 | *.cab
134 | *.msi
135 | *.msm
136 | *.msp
137 |
138 | # Windows shortcuts
139 | *.lnk
140 |
141 | # Editor directories and files
142 | .vscode/*
143 | !.vscode/settings.json
144 | !.vscode/tasks.json
145 | !.vscode/launch.json
146 | !.vscode/extensions.json
147 | *.sublime-workspace
148 |
149 | # IntelliJ related
150 | .idea/
151 | *.iml
152 | *.ipr
153 | *.iws
154 | out/
155 |
--------------------------------------------------------------------------------
/.github/workflows/release-mcp.yml:
--------------------------------------------------------------------------------
1 | name: Reusable MCP Release Workflow
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | package-name:
7 | description: 'NPM package name'
8 | required: true
9 | type: string
10 | package-description:
11 | description: 'Package description'
12 | required: false
13 | type: string
14 | default: 'MCP Server'
15 | entry-point:
16 | description: 'Main entry file'
17 | required: false
18 | type: string
19 | default: 'src/index.js'
20 | include-folders:
21 | description: 'Folders to include (comma-separated)'
22 | required: false
23 | type: string
24 | default: 'src,data,bin'
25 | include-files:
26 | description: 'File patterns to include (comma-separated)'
27 | required: false
28 | type: string
29 | default: '*.json,*.md,LICENSE'
30 | dependencies:
31 | description: 'Additional dependencies as JSON'
32 | required: false
33 | type: string
34 | default: '{}'
35 | build-command:
36 | description: 'Build command to run'
37 | required: false
38 | type: string
39 | default: ''
40 | node-version:
41 | description: 'Node.js version'
42 | required: false
43 | type: string
44 | default: '18'
45 | secrets:
46 | NPM_TOKEN:
47 | required: true
48 |
49 | jobs:
50 | release:
51 | runs-on: ubuntu-latest
52 | steps:
53 | - uses: actions/checkout@v4
54 |
55 | - name: Setup Node.js
56 | uses: actions/setup-node@v4
57 | with:
58 | node-version: ${{ inputs.node-version }}
59 | registry-url: 'https://registry.npmjs.org/'
60 |
61 | - name: Extract Version
62 | run: |
63 | VERSION="${GITHUB_REF#refs/tags/v}"
64 | echo "VERSION=$VERSION" >> $GITHUB_ENV
65 |
66 | - name: Create Package JSON
67 | run: |
68 | # Convert comma-separated to JSON array
69 | FOLDERS='${{ inputs.include-folders }}'
70 | FILES='${{ inputs.include-files }}'
71 |
72 | FILES_ARRAY='['
73 | for folder in ${FOLDERS//,/ }; do
74 | FILES_ARRAY="$FILES_ARRAY\"${folder}/**/*\","
75 | done
76 | for file in ${FILES//,/ }; do
77 | FILES_ARRAY="$FILES_ARRAY\"${file}\","
78 | done
79 | FILES_ARRAY="${FILES_ARRAY%,}]"
80 |
81 | # Default MCP dependencies
82 | BASE_DEPS='{
83 | "@modelcontextprotocol/sdk": "^1.6.0",
84 | "@probelabs/probe": "^0.6.0-rc128",
85 | "axios": "^1.7.2",
86 | "fs-extra": "^11.1.1"
87 | }'
88 |
89 | # Merge with custom dependencies
90 | MERGED_DEPS=$(echo "$BASE_DEPS" | jq -s '.[0] * .[1]' - <(echo '${{ inputs.dependencies }}'))
91 |
92 | cat > package.json << EOF
93 | {
94 | "name": "${{ inputs.package-name }}",
95 | "version": "$VERSION",
96 | "description": "${{ inputs.package-description }}",
97 | "type": "module",
98 | "main": "${{ inputs.entry-point }}",
99 | "files": $FILES_ARRAY,
100 | "dependencies": $MERGED_DEPS,
101 | "keywords": ["mcp", "llm", "ai"],
102 | "license": "MIT"
103 | }
104 | EOF
105 |
106 | - name: Install Dependencies
107 | run: npm install
108 |
109 | - name: Build
110 | if: inputs.build-command != ''
111 | run: ${{ inputs.build-command }}
112 |
113 | - name: Publish
114 | run: npm publish --access public
115 | env:
116 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import minimist from 'minimist';
5 |
6 | // Get the directory of the current module
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 |
10 | // Default configuration
11 | const defaultConfig = {
12 | // Directory to include in the package (for static docs)
13 | includeDir: null,
14 |
15 | // Git repository URL (for dynamic docs)
16 | gitUrl: null,
17 |
18 | // Git branch or tag to checkout
19 | gitRef: 'main',
20 |
21 | // Auto-update interval in minutes (0 to disable)
22 | autoUpdateInterval: 0, // Default to 0 (disabled)
23 |
24 | // Data directory for searching
25 | dataDir: path.resolve(__dirname, '..', 'data'),
26 |
27 | // MCP Tool configuration
28 | toolName: 'search_docs',
29 | toolDescription: 'Search documentation using the probe search engine.',
30 |
31 | // Ignore patterns
32 | ignorePatterns: [
33 | 'node_modules',
34 | '.git',
35 | 'dist',
36 | 'build',
37 | 'coverage'
38 | ],
39 | // Enable cleanup of large/binary files after build (default: true)
40 | enableBuildCleanup: true
41 | };
42 |
43 | /**
44 | * Load configuration from config file and environment variables
45 | * @returns {Object} Configuration object
46 | */
47 | export function loadConfig() {
48 | // Parse command line arguments
49 | const args = minimist(process.argv.slice(2));
50 |
51 | // Check for config file path in arguments
52 | const configPath = args.config || path.resolve(__dirname, '..', 'docs-mcp.config.json');
53 |
54 | let config = { ...defaultConfig };
55 |
56 | // Load configuration from file if it exists
57 | if (fs.existsSync(configPath)) {
58 | try {
59 | const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
60 | config = { ...config, ...fileConfig };
61 | console.error(`Loaded configuration from ${configPath}`);
62 | } catch (error) {
63 | console.error(`Error loading configuration from ${configPath}:`, error);
64 | }
65 | } else {
66 | console.error(`No configuration file found at ${configPath}, using defaults`);
67 | }
68 |
69 | // Override with environment variables
70 | if (process.env.INCLUDE_DIR) config.includeDir = process.env.INCLUDE_DIR;
71 | if (process.env.GIT_URL) config.gitUrl = process.env.GIT_URL;
72 | if (process.env.GIT_REF) config.gitRef = process.env.GIT_REF;
73 | if (process.env.AUTO_UPDATE_INTERVAL) config.autoUpdateInterval = parseInt(process.env.AUTO_UPDATE_INTERVAL, 10);
74 | if (process.env.DATA_DIR) config.dataDir = process.env.DATA_DIR;
75 | if (process.env.TOOL_NAME) config.toolName = process.env.TOOL_NAME;
76 | if (process.env.TOOL_DESCRIPTION) config.toolDescription = process.env.TOOL_DESCRIPTION;
77 | if (process.env.IGNORE_PATTERNS) config.ignorePatterns = process.env.IGNORE_PATTERNS.split(',').map(p => p.trim());
78 |
79 | // Override with command line arguments
80 | if (args.includeDir) config.includeDir = args.includeDir;
81 | if (args.gitUrl) config.gitUrl = args.gitUrl;
82 | if (args.gitRef) config.gitRef = args.gitRef;
83 | if (args.autoUpdateInterval !== undefined) config.autoUpdateInterval = parseInt(args.autoUpdateInterval, 10);
84 | if (args.dataDir) config.dataDir = args.dataDir;
85 | if (args.toolName) config.toolName = args.toolName;
86 | if (args.toolDescription) config.toolDescription = args.toolDescription;
87 | if (args.ignorePatterns) config.ignorePatterns = Array.isArray(args.ignorePatterns) ? args.ignorePatterns : args.ignorePatterns.split(',').map(p => p.trim());
88 | if (args.enableBuildCleanup !== undefined) config.enableBuildCleanup = args.enableBuildCleanup === true || args.enableBuildCleanup === 'true';
89 |
90 | // Ensure dataDir is an absolute path
91 | if (!path.isAbsolute(config.dataDir)) {
92 | config.dataDir = path.resolve(process.cwd(), config.dataDir);
93 | }
94 |
95 | // Ensure includeDir is an absolute path if provided
96 | if (config.includeDir && !path.isAbsolute(config.includeDir)) {
97 | config.includeDir = path.resolve(process.cwd(), config.includeDir);
98 | }
99 |
100 | // Validate configuration
101 | if (config.includeDir && config.gitUrl) {
102 | console.warn('Both includeDir and gitUrl are specified. Using gitUrl.');
103 | config.includeDir = null; // Prioritize gitUrl
104 | }
105 | if (!config.includeDir && !config.gitUrl) {
106 | console.warn('Neither includeDir nor gitUrl is specified. The data directory will be empty unless manually populated.');
107 | }
108 |
109 | return config;
110 | }
111 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import fs from 'fs-extra';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 | import { glob } from 'glob';
6 | import simpleGit from 'simple-git';
7 | import axios from 'axios'; // Import axios
8 | import * as tar from 'tar'; // Import tar using namespace
9 | import { loadConfig } from '../src/config.js';
10 |
11 | // Get the directory of the current module
12 | const __filename = fileURLToPath(import.meta.url);
13 | const __dirname = path.dirname(__filename);
14 | const rootDir = path.resolve(__dirname, '..');
15 |
16 | // Load configuration
17 | const config = loadConfig();
18 |
19 | // Directory to store documentation files
20 | const dataDir = config.dataDir; // Use configured data directory
21 |
22 | /**
23 | * Prepare the data directory by either copying static files or cloning a Git repo.
24 | */
25 | async function prepareDataDir() {
26 | console.log('Building docs-mcp package...');
27 |
28 | // Create data directory if it doesn't exist
29 | await fs.ensureDir(dataDir);
30 |
31 | // Clear the data directory first
32 | await fs.emptyDir(dataDir);
33 |
34 | if (config.gitUrl) {
35 | // Check if we should use Git clone or download tarball
36 | if (config.autoUpdateInterval > 0) {
37 | console.log(`Auto-update enabled (interval: ${config.autoUpdateInterval} mins). Using git clone.`);
38 | await cloneGitRepo();
39 | } else {
40 | console.log('Auto-update disabled. Attempting to download tarball archive.');
41 | await downloadAndExtractTarball();
42 | }
43 | } else if (config.includeDir) {
44 | await copyIncludedDir();
45 | } else {
46 | console.log('No includeDir or gitUrl specified. Created empty data directory.');
47 | }
48 |
49 | // Note: Build completion message moved to the main build function after cleanup
50 | }
51 |
52 | /**
53 | * Clone the Git repository to the data directory.
54 | */
55 | async function cloneGitRepo() {
56 | console.log(`Cloning Git repository ${config.gitUrl} (ref: ${config.gitRef}) to ${dataDir}...`);
57 | const git = simpleGit();
58 |
59 | try {
60 | // Clone with depth 1 as before, but only if auto-update is enabled
61 | await git.clone(config.gitUrl, dataDir, ['--branch', config.gitRef, '--depth', '1']);
62 | console.log(`Successfully cloned repository to ${dataDir}`);
63 | } catch (error) {
64 | console.error(`Error cloning Git repository:`, error);
65 | throw error; // Re-throw to stop the build
66 | }
67 | }
68 |
69 | /**
70 | * Copy included directory to the data directory.
71 | */
72 | async function copyIncludedDir() {
73 | console.log(`Copying ${config.includeDir} to ${dataDir}...`);
74 |
75 | try {
76 | // Get all files in the directory, respecting .gitignore
77 | const files = await glob('**/*', {
78 | cwd: config.includeDir,
79 | nodir: true,
80 | ignore: config.ignorePatterns,
81 | dot: true,
82 | gitignore: true // This will respect .gitignore files
83 | });
84 |
85 | console.log(`Found ${files.length} files in ${config.includeDir} (respecting .gitignore)`);
86 |
87 | // Copy each file
88 | for (const file of files) {
89 | const sourcePath = path.join(config.includeDir, file);
90 | const targetPath = path.join(dataDir, file);
91 |
92 | // Ensure the target directory exists
93 | await fs.ensureDir(path.dirname(targetPath));
94 |
95 | // Copy the file
96 | await fs.copy(sourcePath, targetPath);
97 | }
98 |
99 | console.log(`Successfully copied ${files.length} files to ${dataDir}`);
100 | } catch (error) {
101 | console.error(`Error copying ${config.includeDir}:`, error);
102 | throw error; // Re-throw to stop the build
103 | }
104 | }
105 |
106 | /**
107 | * Downloads and extracts a tarball archive from a Git repository URL.
108 | * Assumes GitHub URL structure for archive download.
109 | */
110 | async function downloadAndExtractTarball() {
111 | // Basic parsing for GitHub URLs (can be made more robust)
112 | const match = config.gitUrl.match(/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/);
113 | if (!match) {
114 | console.error(`Cannot determine tarball URL from gitUrl: ${config.gitUrl}. Falling back to git clone.`);
115 | // Fallback to clone if URL parsing fails or isn't GitHub
116 | await cloneGitRepo();
117 | return;
118 | }
119 |
120 | const owner = match[1];
121 | const repo = match[2];
122 | let ref = config.gitRef || 'main'; // Start with configured ref or default 'main'
123 |
124 | const downloadAttempt = async (currentRef) => {
125 | const tarballUrl = `https://github.com/${owner}/${repo}/archive/${currentRef}.tar.gz`;
126 | console.log(`Attempting to download archive (${currentRef}) from ${tarballUrl} to ${dataDir}...`);
127 |
128 | const response = await axios({
129 | method: 'get',
130 | url: tarballUrl,
131 | responseType: 'stream',
132 | validateStatus: (status) => status >= 200 && status < 300, // Don't throw for non-2xx
133 | });
134 |
135 | // Pipe the download stream directly to tar extractor
136 | await new Promise((resolve, reject) => {
137 | response.data
138 | .pipe(
139 | tar.x({
140 | strip: 1, // Remove the top-level directory
141 | C: dataDir, // Extract to dataDir
142 | })
143 | )
144 | .on('finish', resolve)
145 | .on('error', reject);
146 | });
147 | console.log(`Successfully downloaded and extracted archive (${currentRef}) to ${dataDir}`);
148 | };
149 |
150 | try { // Outer try block (starts line 150 in previous read)
151 | await downloadAttempt(ref);
152 | } catch (error) {
153 | // Check if it was a 404 error and we tried 'main'
154 | if (ref === 'main' && error.response && error.response.status === 404) {
155 | console.warn(`Download failed for ref 'main' (404). Retrying with 'master'...`);
156 | ref = 'master'; // Set ref to master for the retry
157 | try { // Inner try block for master retry (starts line 157 in previous read)
158 | await downloadAttempt(ref);
159 | } catch (retryError) {
160 | console.error(`Retry with 'master' also failed: ${retryError.message}`);
161 | console.error('Falling back to git clone...');
162 | await fallbackToClone(); // Use a separate function for fallback
163 | } // End of inner try block for master retry
164 | } else { // This else belongs to the outer try/catch (line 150)
165 | // Handle other errors (non-404 on 'main', or any error on 'master' or specific ref)
166 | console.error(`Error downloading or extracting tarball (${ref}): ${error.message}`);
167 | console.error('Falling back to git clone...');
168 | await fallbackToClone(); // Use a separate function for fallback
169 | }
170 | } // End of outer try block (starting line 150)
171 | }
172 |
173 | // Helper function for fallback logic
174 | async function fallbackToClone() {
175 | try {
176 | await cloneGitRepo();
177 | } catch (cloneError) {
178 | console.error(`Fallback git clone failed:`, cloneError);
179 | throw cloneError; // Re-throw the clone error if fallback fails
180 | }
181 | }
182 |
183 | /**
184 | * Cleans up the data directory by removing large files and common binary/media types.
185 | */
186 | async function cleanupDataDir() {
187 | console.log(`Cleaning up data directory: ${dataDir}...`);
188 | const files = await glob('**/*', { cwd: dataDir, nodir: true, dot: true, absolute: true });
189 | let removedCount = 0;
190 | const maxSize = 100 * 1024; // 100 KB
191 | const forbiddenExtensions = new Set([
192 | // Images
193 | '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.ico',
194 | // Videos
195 | '.mp4', '.mov', '.avi', '.wmv', '.mkv', '.flv', '.webm',
196 | // Audio
197 | '.mp3', '.wav', '.ogg', '.aac', '.flac',
198 | // Archives
199 | '.zip', '.tar', '.gz', '.bz2', '.rar', '.7z',
200 | // Documents (often large or binary)
201 | '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
202 | // Executables / Libraries
203 | '.exe', '.dll', '.so', '.dylib', '.app',
204 | // Other potentially large/binary
205 | '.psd', '.ai', '.iso', '.dmg', '.pkg', '.deb', '.rpm'
206 | ]);
207 |
208 | for (const file of files) {
209 | try {
210 | const stats = await fs.stat(file);
211 | const ext = path.extname(file).toLowerCase();
212 |
213 | if (forbiddenExtensions.has(ext) || stats.size > maxSize) {
214 | console.log(`Removing: ${file} (Size: ${stats.size} bytes, Ext: ${ext})`);
215 | await fs.remove(file);
216 | removedCount++;
217 | }
218 | } catch (error) {
219 | // Ignore errors for files that might disappear during iteration (e.g., broken symlinks)
220 | if (error.code !== 'ENOENT') {
221 | console.warn(`Could not process file ${file} during cleanup: ${error.message}`);
222 | }
223 | }
224 | }
225 | console.log(`Cleanup complete. Removed ${removedCount} files.`);
226 | }
227 |
228 | /**
229 | * Create a default configuration file if it doesn't exist.
230 | */
231 | async function createDefaultConfig() {
232 | const configPath = path.join(rootDir, 'docs-mcp.config.json');
233 |
234 | if (!fs.existsSync(configPath)) {
235 | const defaultConfig = {
236 | includeDir: null,
237 | gitUrl: null,
238 | gitRef: 'main',
239 | autoUpdateInterval: 0, // Default changed previously
240 | enableBuildCleanup: true, // Added previously
241 | ignorePatterns: [
242 | 'node_modules',
243 | '.git',
244 | 'dist',
245 | 'build',
246 | 'coverage'
247 | ]
248 | };
249 |
250 | await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2));
251 | console.log(`Created default configuration file at ${configPath}`);
252 | }
253 | }
254 |
255 | // Run the build process
256 | async function build() {
257 | try {
258 | await createDefaultConfig();
259 | await prepareDataDir();
260 |
261 | // Perform cleanup if enabled
262 | if (config.enableBuildCleanup) {
263 | await cleanupDataDir();
264 | } else {
265 | console.log('Build cleanup is disabled via configuration.');
266 | }
267 |
268 | // Make the bin script executable
269 | const binPath = path.join(rootDir, 'bin', 'mcp');
270 | await fs.chmod(binPath, 0o755);
271 | console.log(`Made bin script executable: ${binPath}`);
272 |
273 | console.log('Build process finished successfully!'); // Moved completion message here
274 | } catch (error) {
275 | console.error('Build failed:', error);
276 | process.exit(1);
277 | }
278 | }
279 |
280 | build();
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Docs MCP Server
2 |
3 | A flexible Model Context Protocol (MCP) server powered by [Probe](https://probeai.dev/) that makes any documentation or codebase searchable by AI assistants.
4 |
5 | Chat with code or your docs by simply pointing to a git repo or folder:
6 |
7 | ```bash
8 | npx -y @probelabs/docs-mcp@latest --gitUrl https://github.com/probelabs/probe
9 | ```
10 |
11 | **Use Cases:**
12 |
13 | * **Chat with any GitHub Repository:** Point the server to a public or private Git repository to enable natural language queries about its contents.
14 | * **Search Your Documentation:** Integrate your project's documentation (from a local directory or Git) for easy searching.
15 | * **Build Custom MCP Servers:** Use this project as a template to create your own official MCP servers tailored to specific documentation sets or even codebases.
16 |
17 | The content source (documentation or code) can be **pre-built** into the package during the `npm run build` step, or configured **dynamically** at runtime using local directories or Git repositories. By default, when using a `gitUrl` without enabling auto-updates, the server downloads a `.tar.gz` archive for faster startup. Full Git cloning is used only when `autoUpdateInterval` is greater than 0.
18 |
19 | ## Features
20 |
21 | - **Powered by Probe:** Leverages the [Probe](https://probeai.dev/) search engine for efficient and relevant results.
22 | - **Flexible Content Sources:** Include a specific local directory or clone a Git repository.
23 | - **Pre-build Content:** Optionally bundle documentation/code content directly into the package.
24 | - **Dynamic Configuration:** Configure content sources, Git settings, and MCP tool details via config file, CLI arguments, or environment variables.
25 | - **Automatic Git Updates:** Keep content fresh by automatically pulling changes from a Git repository at a configurable interval.
26 | - **Customizable MCP Tool:** Define the name and description of the search tool exposed to AI assistants.
27 | - **AI Integration:** Seamlessly integrates with AI assistants supporting the Model Context Protocol (MCP).
28 |
29 | ## Installation
30 |
31 | ### Quick Start with Claude Desktop
32 |
33 | Add to your Claude Desktop configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
34 |
35 | ```json
36 | {
37 | "mcpServers": {
38 | "docs-search": {
39 | "command": "npx",
40 | "args": [
41 | "-y",
42 | "@probelabs/docs-mcp@latest",
43 | "--gitUrl",
44 | "https://github.com/your-org/your-repo",
45 | "--toolName",
46 | "search_docs",
47 | "--toolDescription",
48 | "Search documentation"
49 | ]
50 | }
51 | }
52 | }
53 | ```
54 |
55 | ### MCP Client Integration
56 |
57 | You can configure your MCP client to launch this server using `npx`. Here are examples of how you might configure a client (syntax may vary based on the specific client):
58 |
59 | **Example 1: Dynamically Searching a Git Repository (Tyk Docs)**
60 |
61 | This configuration tells the client to run the latest `@probelabs/docs-mcp` package using `npx`, pointing it dynamically to the Tyk documentation repository. The `-y` argument automatically confirms the `npx` installation prompt. The `--toolName` and `--toolDescription` arguments customize how the search tool appears to the AI assistant.
62 |
63 | ```json
64 | {
65 | "mcpServers": {
66 | "tyk-docs-search": {
67 | "command": "npx",
68 | "args": [
69 | "-y",
70 | "@probelabs/docs-mcp@latest",
71 | "--gitUrl",
72 | "https://github.com/TykTechnologies/tyk-docs",
73 | "--toolName",
74 | "search_tyk_docs",
75 | "--toolDescription",
76 | "Search Tyk API Management Documentation"
77 | ],
78 | "enabled": true
79 | }
80 | }
81 | }
82 | ```
83 |
84 | Alternatively, some clients might allow specifying the full command directly. You could achieve the same as Example 1 using:
85 |
86 | ```bash
87 | npx -y @probelabs/docs-mcp@latest --gitUrl https://github.com/TykTechnologies/tyk-docs --toolName search_tyk_docs --toolDescription "Search Tyk API Management Documentation"
88 | ```
89 |
90 | **Example 2: Using a Pre-built, Branded MCP Server (e.g., Tyk Package)**
91 |
92 | If a team publishes a pre-built package containing specific documentation (like `@tyk-technologies/docs-mcp`), the configuration becomes simpler as the content source and tool details are baked into that package. The `-y` argument is still recommended for `npx`.
93 |
94 | ```json
95 | {
96 | "mcpServers": {
97 | "tyk-official-docs": {
98 | "command": "npx",
99 | "args": [
100 | "-y",
101 | "@tyk-technologies/docs-mcp@latest"
102 | ],
103 | "enabled": true
104 | }
105 | }
106 | }
107 | ```
108 |
109 | This approach is ideal for distributing standardized search experiences for official documentation or codebases. See the "Creating Your Own Pre-built MCP Server" section below.
110 |
111 | Here is example on how Tyk team have build own documentation MCP server https://github.com/TykTechnologies/docs-mcp.
112 |
113 | ## Configuration
114 |
115 | Create a `docs-mcp.config.json` file in the root directory to define the **default** content source and MCP tool details used during the build and at runtime (unless overridden by CLI arguments or environment variables).
116 |
117 | ### Example 1: Using a Local Directory
118 |
119 | ```json
120 | {
121 | "includeDir": "/Users/username/projects/my-project/docs",
122 | "toolName": "search_my_project_docs",
123 | "toolDescription": "Search the documentation for My Project.",
124 | "ignorePatterns": [
125 | "node_modules",
126 | ".git",
127 | "build",
128 | "*.log"
129 | ]
130 | }
131 | ```
132 |
133 | ### Example 2: Using a Git Repository
134 |
135 | ```json
136 | {
137 | "gitUrl": "https://github.com/your-org/your-codebase.git",
138 | "gitRef": "develop",
139 | "autoUpdateInterval": 15,
140 | "toolName": "search_codebase",
141 | "toolDescription": "Search the main company codebase.",
142 | "ignorePatterns": [
143 | "*.test.js",
144 | "dist/",
145 | "__snapshots__"
146 | ]
147 | }
148 | ```
149 |
150 | ### Configuration Options
151 |
152 | - `includeDir`: **(Build/Runtime)** Absolute path to a local directory whose contents will be copied to the `data` directory during build, or used directly at runtime if `dataDir` is not specified. Use this OR `gitUrl`.
153 | - `gitUrl`: **(Build/Runtime)** URL of the Git repository. Use this OR `includeDir`.
154 | - If `autoUpdateInterval` is 0 (default), the server attempts to download a `.tar.gz` archive directly (currently assumes GitHub URL structure: `https://github.com/{owner}/{repo}/archive/{ref}.tar.gz`). This is faster but doesn't support updates.
155 | - If `autoUpdateInterval` > 0, the server performs a `git clone` and enables periodic updates.
156 | - `gitRef`: **(Build/Runtime)** The branch, tag, or commit hash to use from the `gitUrl` (default: `main`). Used for both tarball download and Git clone/pull.
157 | - `autoUpdateInterval`: **(Runtime)** Interval in minutes to automatically check for Git updates (default: 0, meaning disabled). Setting this to a value > 0 enables Git cloning and periodic `git pull` operations. Requires the `git` command to be available in the system path.
158 | - `dataDir`: **(Runtime)** Path to the directory containing the content to be searched at runtime. Overrides content sourced from `includeDir` or `gitUrl` defined in the config file or built into the package. Useful for pointing the server to live data without rebuilding.
159 | - `toolName`: **(Build/Runtime)** The name of the MCP tool exposed by the server (default: `search_docs`). Choose a descriptive name relevant to the content.
160 | - `toolDescription`: **(Build/Runtime)** The description of the MCP tool shown to AI assistants (default: "Search documentation using the probe search engine.").
161 | - `ignorePatterns`: **(Build/Runtime)** An array of glob patterns.
162 | - `enableBuildCleanup`: **(Build)** If `true` (default), removes common binary/media files (images, videos, archives, etc.) and files larger than 100KB from the `data` directory after the build step. Set to `false` to disable this cleanup.
163 | - If using `includeDir` during build: Files matching these patterns are excluded when copying to `data`. `.gitignore` rules are also respected.
164 | - If using `gitUrl` or `dataDir` at runtime: Files matching these patterns within the `data` directory are ignored by the search indexer.
165 |
166 | **Precedence:**
167 |
168 | 1. **Runtime Configuration (Highest):** CLI arguments (`--dataDir`, `--gitUrl`, etc.) and Environment Variables (`DATA_DIR`, `GIT_URL`, etc.) override all other settings. CLI arguments take precedence over Environment Variables.
169 | 2. **Build-time Configuration:** Settings in `docs-mcp.config.json` (`includeDir`, `gitUrl`, `toolName`, etc.) define defaults used during `npm run build` and also serve as runtime defaults if not overridden.
170 | 3. **Default Values (Lowest):** Internal defaults are used if no configuration is provided (e.g., `toolName: 'search_docs'`, `autoUpdateInterval: 5`).
171 |
172 | Note: If both `includeDir` and `gitUrl` are provided in the *same* configuration source (e.g., both in the config file, or both as CLI args), `gitUrl` takes precedence.
173 |
174 | ## Creating Your Own Pre-built MCP Server
175 |
176 | You can use this project as a template to create and publish your own npm package with documentation or code pre-built into it. This provides a zero-configuration experience for users (like Example 2 above).
177 |
178 | 1. **Fork/Clone this Repository:** Start with this project's code.
179 | 2. **Configure `docs-mcp.config.json`:** Define the `includeDir` or `gitUrl` pointing to your content source. Set the default `toolName` and `toolDescription`.
180 | 3. **Update `package.json`:** Change the `name` (e.g., `@my-org/my-docs-mcp`), `version`, `description`, etc.
181 | 4. **Build:** Run `npm run build`. This clones/copies your content into the `data` directory and makes the package ready.
182 | 5. **Publish:** Run `npm publish` (you'll need npm authentication configured).
183 |
184 | Now, users can run your specific documentation server easily: `npx @my-org/my-docs-mcp@latest`.
185 |
186 | *(The previous "Running", "Dynamic Configuration at Runtime", and "Environment Variables" sections have been removed as `npx` usage with arguments within client configurations is now the primary documented method.)*
187 |
188 | ## Using with AI Assistants
189 |
190 | This MCP server exposes a search tool to connected AI assistants via the Model Context Protocol. The tool's name and description are configurable (see Configuration section). It searches the content within the currently active `data` directory (determined by build settings, config file, CLI args, or environment variables).
191 |
192 | **Tool Parameters:**
193 |
194 | - `query`: A natural language query or keywords describing what to search for (e.g., "how to configure the gateway", "database connection example", "user authentication"). The server uses Probe's search capabilities to find relevant content. (Required)
195 | - `page`: The page number for results when dealing with many matches. Defaults to 1 if omitted. (Optional)
196 |
197 | **Example Tool Call (using `search_tyk_docs` from Usage Example 1):**
198 |
199 | ```json
200 | {
201 | "tool_name": "search_tyk_docs",
202 | "arguments": {
203 | "query": "gateway rate limiting",
204 | "page": 1 // Requesting the first page
205 | }
206 | }
207 | ```
208 |
209 | **Example Tool Call (using the tool from the `@tyk/docs-mcp` package):**
210 |
211 | Assuming the pre-built package `@tyk/docs-mcp` defined its tool name as `search_tyk_official_docs`:
212 |
213 | ```json
214 | {
215 | "tool_name": "search_tyk_official_docs",
216 | "arguments": {
217 | "query": "dashboard api access",
218 | "page": 2 // Requesting the second page
219 | }
220 | }
221 | ```
222 |
223 | *(The previous "Publishing as an npm Package" section has been replaced by the "Creating Your Own Pre-built MCP Server" section above.)*
224 |
225 | ## Third-Party Integrations
226 |
227 | ### Install via Smithery
228 |
229 | To install Docs MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@probelabs/docs-mcp):
230 |
231 | ```bash
232 | npx -y @smithery/cli install @probelabs/docs-mcp --client claude
233 | ```
234 |
235 | [](https://smithery.ai/server/@probelabs/docs-mcp)
236 |
237 | ### Community Listings
238 |
239 |
240 |
241 |
242 |
243 | [](https://mseep.ai/app/probelabs-docs-mcp)
244 |
245 | ## Automated NPM Releases with GitHub Actions
246 |
247 | This project includes a reusable GitHub Actions workflow that makes releasing MCP servers to NPM incredibly simple. You can use this workflow in any project to automatically build and publish your MCP server when you push a git tag.
248 |
249 | ### Using the Reusable Release Workflow
250 |
251 | To use this automated release system in your own project, create a single file `.github/workflows/release.yml`:
252 |
253 | ```yaml
254 | name: Release MCP
255 |
256 | on:
257 | push:
258 | tags:
259 | - 'v*'
260 |
261 | jobs:
262 | release:
263 | uses: probelabs/docs-mcp/.github/workflows/release-mcp.yml@main
264 | with:
265 | package-name: '@yourorg/your-mcp-server'
266 | package-description: 'Your MCP Server Description'
267 | include-folders: 'src,data,bin' # Folders to include in the package
268 | include-files: '*.json,*.md' # File patterns to include
269 | secrets:
270 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
271 | ```
272 |
273 | Then simply create a git tag to trigger a release:
274 |
275 | ```bash
276 | git tag v1.0.0
277 | git push origin v1.0.0
278 | ```
279 |
280 | ### Workflow Input Parameters
281 |
282 | | Parameter | Required | Default | Description |
283 | |-----------|----------|---------|-------------|
284 | | `package-name` | Yes | - | NPM package name (e.g., `@org/my-mcp`) |
285 | | `package-description` | No | `MCP Server` | Package description |
286 | | `entry-point` | No | `src/index.js` | Main entry file path |
287 | | `include-folders` | No | `src,data,bin` | Comma-separated list of folders to include |
288 | | `include-files` | No | `*.json,*.md,LICENSE` | Comma-separated list of file patterns |
289 | | `dependencies` | No | `{}` | Additional dependencies as JSON string |
290 | | `build-command` | No | - | Build command to run before publishing |
291 | | `node-version` | No | `18` | Node.js version to use |
292 |
293 | ### Example Configurations
294 |
295 | #### Minimal Configuration
296 | ```yaml
297 | jobs:
298 | release:
299 | uses: probelabs/docs-mcp/.github/workflows/release-mcp.yml@main
300 | with:
301 | package-name: '@myorg/simple-mcp'
302 | secrets:
303 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
304 | ```
305 |
306 | #### With Custom Dependencies
307 | ```yaml
308 | jobs:
309 | release:
310 | uses: probelabs/docs-mcp/.github/workflows/release-mcp.yml@main
311 | with:
312 | package-name: '@myorg/custom-mcp'
313 | dependencies: '{"lodash": "^4.17.21", "dotenv": "^16.0.0"}'
314 | secrets:
315 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
316 | ```
317 |
318 | #### With Build Step
319 | ```yaml
320 | jobs:
321 | release:
322 | uses: probelabs/docs-mcp/.github/workflows/release-mcp.yml@main
323 | with:
324 | package-name: '@myorg/built-mcp'
325 | build-command: 'npm run build && npm run prepare-data'
326 | include-folders: 'dist,assets'
327 | secrets:
328 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
329 | ```
330 |
331 | ### Prerequisites
332 |
333 | 1. Add `NPM_TOKEN` secret to your GitHub repository (Settings → Secrets → Actions)
334 | 2. Ensure you have npm publish access for your organization/scope
335 |
336 | The workflow automatically:
337 | - Extracts version from git tags (e.g., `v1.0.0` → `1.0.0`)
338 | - Generates a complete `package.json` with MCP dependencies
339 | - Runs optional build commands
340 | - Publishes to NPM with public access
341 |
342 | ## License
343 |
344 | MIT
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import {
5 | CallToolRequestSchema,
6 | ErrorCode,
7 | ListToolsRequestSchema,
8 | McpError,
9 | } from '@modelcontextprotocol/sdk/types.js';
10 | import path from 'path';
11 | import fs from 'fs-extra';
12 | import { fileURLToPath } from 'url';
13 | import { search } from '@probelabs/probe';
14 | import simpleGit from 'simple-git';
15 | import axios from 'axios'; // Import axios
16 | import * as tar from 'tar'; // Import tar using namespace
17 | import { loadConfig } from './config.js';
18 | import minimist from 'minimist'; // Import minimist to check runtime args
19 |
20 | // Get the package.json to determine the version
21 | const __filename = fileURLToPath(import.meta.url);
22 | const __dirname = path.dirname(__filename);
23 | const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
24 |
25 | // Get version from package.json
26 | let packageVersion = '0.1.0';
27 | try {
28 | if (fs.existsSync(packageJsonPath)) {
29 | console.error(`Found package.json at: ${packageJsonPath}`);
30 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
31 | if (packageJson.version) {
32 | packageVersion = packageJson.version;
33 | console.error(`Using version from package.json: ${packageVersion}`);
34 | }
35 | }
36 | } catch (error) {
37 | console.error(`Error reading package.json:`, error);
38 | }
39 |
40 | // Load configuration (handles defaults, file, env, args precedence)
41 | const config = loadConfig();
42 |
43 | // Git instance - initialize lazily only if needed for auto-updates
44 | let git = null;
45 |
46 | // Ensure the data directory exists (might be empty initially)
47 | try {
48 | fs.ensureDirSync(config.dataDir);
49 | console.error(`Ensured data directory exists: ${config.dataDir}`);
50 | } catch (err) {
51 | console.error(`Failed to ensure data directory exists: ${config.dataDir}`, err);
52 | process.exit(1);
53 | }
54 |
55 | // Auto-update timer
56 | let updateTimer = null;
57 |
58 | /**
59 | * @typedef {Object} SearchDocsArgs
60 | * @property {string|string[]} query - The search query using Elasticsearch syntax. Focus on keywords.
61 | */
62 |
63 | class DocsMcpServer {
64 | constructor() {
65 | /**
66 | * @type {Server}
67 | * @private
68 | */
69 | this.server = new Server(
70 | {
71 | name: '@probelabs/probe-docs-mcp', // Keep server name static
72 | version: packageVersion,
73 | },
74 | {
75 | capabilities: {
76 | tools: {},
77 | },
78 | }
79 | );
80 |
81 | this.setupToolHandlers();
82 |
83 | // Error handling
84 | this.server.onerror = (error) => console.error('[MCP Error]', error);
85 | process.on('SIGINT', async () => {
86 | if (updateTimer) clearTimeout(updateTimer);
87 | await this.server.close();
88 | process.exit(0);
89 | });
90 | }
91 |
92 | /**
93 | * Set up the tool handlers for the MCP server
94 | * @private
95 | */
96 | setupToolHandlers() {
97 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
98 | tools: [
99 | {
100 | name: config.toolName, // Use configured tool name
101 | description: config.toolDescription, // Use configured description
102 | inputSchema: {
103 | type: 'object',
104 | properties: {
105 | query: {
106 | type: 'string',
107 | description: 'Elasticsearch query string. Focus on keywords and use ES syntax (e.g., "install AND guide", "configure OR setup", "api NOT internal").',
108 | },
109 | page: {
110 | type: 'number',
111 | description: 'Optional page number for pagination of results (e.g., 1, 2, 3...). Default is 1.',
112 | default: 1, // Set a default value
113 | }
114 | },
115 | required: ['query'] // 'page' is optional
116 | },
117 | },
118 | ],
119 | }));
120 |
121 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
122 | // Check against the configured tool name
123 | if (request.params.name !== config.toolName) {
124 | throw new McpError(
125 | ErrorCode.MethodNotFound,
126 | `Unknown tool: ${request.params.name}. Expected: ${config.toolName}`
127 | );
128 | }
129 |
130 | try {
131 | // Log the incoming request for debugging
132 | console.error(`Received request for tool: ${request.params.name}`);
133 | console.error(`Request arguments: ${JSON.stringify(request.params.arguments)}`);
134 |
135 | // Ensure arguments is an object
136 | if (!request.params.arguments || typeof request.params.arguments !== 'object') {
137 | throw new Error("Arguments must be an object");
138 | }
139 |
140 | const args = request.params.arguments;
141 |
142 | // Validate required fields
143 | if (!args.query) {
144 | throw new Error("Query is required in arguments");
145 | }
146 |
147 | const result = await this.executeDocsSearch(args);
148 |
149 | return {
150 | content: [
151 | {
152 | type: 'text',
153 | text: result,
154 | },
155 | ],
156 | };
157 | } catch (error) {
158 | console.error(`Error executing ${request.params.name}:`, error);
159 | return {
160 | content: [
161 | {
162 | type: 'text',
163 | text: `Error executing ${request.params.name}: ${error instanceof Error ? error.message : String(error)}`,
164 | },
165 | ],
166 | isError: true,
167 | };
168 | }
169 | });
170 | }
171 |
172 | /**
173 | * Execute a documentation search
174 | * @param {SearchDocsArgs} args - Search arguments
175 | * @returns {Promise} Search results
176 | * @private
177 | */
178 | async executeDocsSearch(args) {
179 | try {
180 | // Always use the configured data directory
181 | const searchPath = config.dataDir;
182 |
183 | // Create a clean options object
184 | const options = {
185 | path: searchPath,
186 | query: args.query,
187 | maxTokens: 10000 // Set default maxTokens
188 | // Removed filesOnly, maxResults, session
189 | };
190 |
191 | console.error("Executing search with options:", JSON.stringify(options, null, 2));
192 |
193 | // Call search with the options object
194 | const result = await search(options);
195 | return result;
196 | } catch (error) {
197 | console.error('Error executing docs search:', error);
198 | throw new McpError(
199 | ErrorCode.MethodNotFound,
200 | `Error executing docs search: ${error.message || String(error)}`
201 | );
202 | }
203 | }
204 |
205 | /**
206 | * Check for updates in the Git repository and pull changes.
207 | * @private
208 | */
209 | async checkForUpdates() {
210 | if (!config.gitUrl || !git) return; // Only update if gitUrl is configured and git is initialized
211 |
212 | console.log('Checking for documentation updates...');
213 | try {
214 | // Check if it's a valid Git repository
215 | const isRepo = await git.checkIsRepo();
216 | if (!isRepo) {
217 | console.error(`Data directory ${config.dataDir} is not a Git repository. Skipping update.`);
218 | return;
219 | }
220 |
221 | // Fetch updates from remote
222 | await git.fetch();
223 |
224 | // Check status
225 | const status = await git.status();
226 |
227 | if (status.behind > 0) {
228 | console.log(`Local branch is ${status.behind} commits behind origin/${status.tracking}. Pulling updates...`);
229 | await git.pull('origin', config.gitRef);
230 | console.log('Documentation updated successfully.');
231 | } else {
232 | console.log('Documentation is up-to-date.');
233 | }
234 | } catch (error) {
235 | console.error('Error checking for updates:', error);
236 | } finally {
237 | // Schedule the next check
238 | if (config.autoUpdateInterval > 0) {
239 | updateTimer = setTimeout(() => this.checkForUpdates(), config.autoUpdateInterval * 60 * 1000);
240 | }
241 | }
242 | }
243 |
244 | /**
245 | * Downloads and extracts a tarball archive from a Git repository URL.
246 | * Assumes GitHub URL structure for archive download.
247 | * @private
248 | */
249 | async downloadAndExtractTarballRuntime() {
250 | // Basic parsing for GitHub URLs (can be made more robust)
251 | const match = config.gitUrl.match(/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/);
252 | if (!match) {
253 | console.error(`Cannot determine tarball URL from gitUrl: ${config.gitUrl}. Cannot proceed.`);
254 | throw new Error('Invalid or unsupported Git URL for tarball download.');
255 | }
256 |
257 | const owner = match[1];
258 | const repo = match[2];
259 | let ref = config.gitRef || 'main'; // Start with configured ref or default 'main'
260 |
261 | const downloadAttempt = async (currentRef) => {
262 | const tarballUrl = `https://github.com/${owner}/${repo}/archive/${currentRef}.tar.gz`;
263 | console.log(`Attempting to download archive (${currentRef}) from ${tarballUrl} to ${config.dataDir}...`);
264 |
265 | // Clear directory before extracting
266 | await fs.emptyDir(config.dataDir);
267 |
268 | const response = await axios({
269 | method: 'get',
270 | url: tarballUrl,
271 | responseType: 'stream',
272 | validateStatus: (status) => status >= 200 && status < 300, // Don't throw for non-2xx
273 | });
274 |
275 | // Pipe the download stream directly to tar extractor
276 | await new Promise((resolve, reject) => {
277 | response.data
278 | .pipe(
279 | tar.x({
280 | strip: 1, // Remove the top-level directory
281 | C: config.dataDir, // Extract to dataDir
282 | })
283 | )
284 | .on('finish', resolve)
285 | .on('error', reject);
286 | });
287 | console.log(`Successfully downloaded and extracted archive (${currentRef}) to ${config.dataDir}`);
288 | };
289 |
290 | try { // Outer try block
291 | await downloadAttempt(ref);
292 | } catch (error) {
293 | // Check if it was a 404 error and we tried 'main'
294 | if (ref === 'main' && error.response && error.response.status === 404) {
295 | console.warn(`Download failed for ref 'main' (404). Retrying with 'master'...`);
296 | ref = 'master'; // Set ref to master for the retry
297 | try { // Inner try block for master retry
298 | await downloadAttempt(ref);
299 | } catch (retryError) {
300 | console.error(`Retry with 'master' also failed: ${retryError.message}`);
301 | // Unlike build, we might not want to fallback to clone here, just fail.
302 | throw new Error(`Failed to download archive for both 'main' and 'master' refs.`);
303 | } // End of inner try block for master retry
304 | } else { // This else belongs to the outer try/catch
305 | // Handle other errors (non-404 on 'main', or any error on 'master' or specific ref)
306 | console.error(`Error downloading or extracting tarball (${ref}): ${error.message}`);
307 | throw error; // Re-throw original error
308 | }
309 | } // End of outer try block
310 | }
311 |
312 |
313 | async run() {
314 | try {
315 | console.error("Starting Docs MCP server...");
316 | console.error(`Using data directory: ${config.dataDir}`);
317 | console.error(`MCP Tool Name: ${config.toolName}`);
318 | console.error(`MCP Tool Description: ${config.toolDescription}`);
319 | if (config.gitUrl) {
320 | console.error(`Using Git repository: ${config.gitUrl} (ref: ${config.gitRef})`);
321 | console.error(`Auto-update interval: ${config.autoUpdateInterval} minutes`);
322 | } else if (config.includeDir) {
323 | console.error(`Using static directory: ${config.includeDir}`);
324 | }
325 |
326 | // --- Check for Runtime Overrides and Pre-built Data ---
327 | const args = minimist(process.argv.slice(2));
328 | const runtimeOverride = args.dataDir || args.gitUrl || args.includeDir ||
329 | process.env.DATA_DIR || process.env.GIT_URL || process.env.INCLUDE_DIR;
330 |
331 | let usePrebuiltData = false;
332 | if (!runtimeOverride) {
333 | // No runtime overrides, check if default dataDir (inside package) has content
334 | try {
335 | // config.dataDir should point to the default 'data' dir inside the package here
336 | if (fs.existsSync(config.dataDir)) {
337 | const items = await fs.readdir(config.dataDir);
338 | if (items.length > 0) {
339 | usePrebuiltData = true;
340 | console.error(`Detected non-empty default data directory. Using pre-built content from ${config.dataDir}.`);
341 | } else {
342 | console.error(`Default data directory ${config.dataDir} exists but is empty. Will attempt fetch based on config.`);
343 | }
344 | } else {
345 | // This case should be rare if build script ran correctly
346 | console.error(`Default data directory ${config.dataDir} does not exist. Will attempt fetch based on config.`);
347 | }
348 | } catch (readDirError) {
349 | console.error(`Error checking default data directory ${config.dataDir}:`, readDirError);
350 | // Proceed to fetch based on config as we can't confirm pre-built state
351 | }
352 | } else {
353 | console.error('Runtime content source override detected. Ignoring pre-built data check.');
354 | }
355 | // --- End Check ---
356 |
357 |
358 | // --- Handle Content Source Initialization ---
359 | if (usePrebuiltData) {
360 | // Skip fetching, use existing data
361 | console.error("Skipping content fetch, using pre-built data.");
362 | if (updateTimer) clearTimeout(updateTimer); // Ensure no updates are scheduled
363 | updateTimer = null;
364 | } else if (config.gitUrl) {
365 | // --- Attempt fetch via Git URL (Tarball or Clone) ---
366 | if (config.autoUpdateInterval > 0) {
367 | // --- Auto-update enabled: Use Git ---
368 | console.error(`Auto-update enabled. Initializing Git for ${config.dataDir}...`);
369 | if (!git) git = simpleGit(config.dataDir); // Initialize git instance only when needed
370 | const isRepo = await git.checkIsRepo();
371 |
372 | if (!isRepo) {
373 | console.error(`Directory ${config.dataDir} is not a Git repository. Attempting initial clone...`);
374 | try {
375 | // Ensure directory is empty before cloning
376 | await fs.emptyDir(config.dataDir);
377 | await simpleGit().clone(config.gitUrl, config.dataDir, ['--branch', config.gitRef, '--depth', '1']);
378 | console.error(`Successfully cloned ${config.gitUrl} to ${config.dataDir}`);
379 | } catch (cloneError) {
380 | console.error(`Error during initial clone:`, cloneError);
381 | process.exit(1);
382 | }
383 | }
384 | // If it was already a repo OR clone succeeded, check for updates
385 | console.error(`Directory ${config.dataDir} is a Git repository. Proceeding with update check.`);
386 | await this.checkForUpdates(); // Run initial check, then schedule next
387 |
388 | } else {
389 | // --- Auto-update disabled: Use Tarball Download ---
390 | console.error(`Auto-update disabled. Initializing content from tarball for ${config.gitUrl}...`);
391 | try {
392 | await this.downloadAndExtractTarballRuntime();
393 | } catch (tarballError) {
394 | console.error(`Failed to initialize from tarball:`, tarballError);
395 | process.exit(1);
396 | }
397 | if (updateTimer) clearTimeout(updateTimer);
398 | updateTimer = null;
399 | }
400 | } else if (config.includeDir && !runtimeOverride) {
401 | // --- Use includeDir from config ONLY if no runtime override and no prebuilt data found ---
402 | console.error(`Warning: Config specifies includeDir (${config.includeDir}) but data dir (${config.dataDir}) is empty/missing and no runtime override provided. Data might be missing if build step wasn't run or failed.`);
403 | if (updateTimer) clearTimeout(updateTimer);
404 | updateTimer = null;
405 | } else if (!runtimeOverride) {
406 | // No runtime override, no prebuilt data, no gitUrl, no includeDir in config
407 | console.error(`No content source specified and no pre-built data found in ${config.dataDir}. Server may have no data to search.`);
408 | if (updateTimer) clearTimeout(updateTimer);
409 | updateTimer = null;
410 | }
411 | // If runtimeOverride was set, the config loading already handled setting config.dataDir/gitUrl etc.
412 | // and the logic above will use those values if usePrebuiltData is false.
413 | // --- End Content Source Initialization ---
414 |
415 |
416 | // Connect the server to the transport
417 | const transport = new StdioServerTransport();
418 | await this.server.connect(transport);
419 | console.error('Docs MCP server running on stdio');
420 | } catch (error) {
421 | console.error('Error starting server:', error);
422 | process.exit(1);
423 | }
424 | }
425 | }
426 |
427 | const server = new DocsMcpServer();
428 | server.run().catch(console.error);
--------------------------------------------------------------------------------