├── 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 | [![smithery badge](https://smithery.ai/badge/@probelabs/docs-mcp)](https://smithery.ai/server/@probelabs/docs-mcp) 236 | 237 | ### Community Listings 238 | 239 | 240 | Docs Server MCP server 241 | 242 | 243 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/probelabs-docs-mcp-badge.png)](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); --------------------------------------------------------------------------------