devdocs-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -0
- package/package.json +38 -0
- package/src/index.ts +19 -0
- package/src/server.ts +96 -0
- package/src/services/devdocs-client.ts +181 -0
- package/src/services/file-cache.ts +121 -0
- package/src/tools/index.ts +85 -0
- package/src/types/devdocs.ts +51 -0
- package/src/utils/html-to-markdown.ts +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# DevDocs MCP Server
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server that provides API documentation from [DevDocs.io](https://devdocs.io) to AI assistants.
|
|
4
|
+
|
|
5
|
+
**Runtime Requirement:** This server requires **Bun >= 1.0.0**.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **List Documentation** - Browse all available documentation libraries
|
|
10
|
+
- **Fuzzy Search** - Typo-tolerant search with relevance scoring powered by [Fuse.js](https://fusejs.io/)
|
|
11
|
+
- **Get Documentation Pages** - Retrieve full documentation content in Markdown format
|
|
12
|
+
- **File-based Caching** - Reduces API calls with configurable TTL
|
|
13
|
+
- **Version Support** - Access specific library versions (e.g., `react~18`, `python~3.12`)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**This server only supports Bun runtime.**
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Start the MCP Server
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
DEBUG=mcp:* bun start
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Configure in Claude Desktop
|
|
32
|
+
|
|
33
|
+
After installing from npm, use `bunx`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"devdocs": {
|
|
39
|
+
"command": "bunx",
|
|
40
|
+
"args": ["devdocs-mcp"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For local development:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"devdocs": {
|
|
52
|
+
"command": "bun",
|
|
53
|
+
"args": ["run", "/path/to/devdocs-mcp/src/index.ts"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Configure in Cursor
|
|
60
|
+
|
|
61
|
+
After installing from npm, use `bunx`:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"mcpServers": {
|
|
66
|
+
"devdocs": {
|
|
67
|
+
"command": "bunx",
|
|
68
|
+
"args": ["devdocs-mcp"]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For local development:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"mcpServers": {
|
|
79
|
+
"devdocs": {
|
|
80
|
+
"command": "bun",
|
|
81
|
+
"args": ["run", "/path/to/devdocs-mcp/src/index.ts"]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## MCP Tools
|
|
88
|
+
|
|
89
|
+
### `list_available_docs`
|
|
90
|
+
|
|
91
|
+
List all available documentation libraries from DevDocs.
|
|
92
|
+
|
|
93
|
+
**Parameters:**
|
|
94
|
+
|
|
95
|
+
- `filter` (optional): Filter libraries by name (e.g., "react", "python")
|
|
96
|
+
|
|
97
|
+
**Example:**
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
List all React documentation
|
|
101
|
+
→ list_available_docs({ filter: "react" })
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `search_docs`
|
|
105
|
+
|
|
106
|
+
Search within a library's documentation with fuzzy matching support.
|
|
107
|
+
|
|
108
|
+
**Parameters:**
|
|
109
|
+
|
|
110
|
+
- `library` (required): Library slug (e.g., "react", "typescript", "python~3.12")
|
|
111
|
+
- `query` (required): Search query
|
|
112
|
+
- `limit` (optional): Maximum results (default: 10)
|
|
113
|
+
- `fuzzy` (optional): Enable fuzzy search for typo-tolerant matching (default: true)
|
|
114
|
+
- `threshold` (optional): Fuzzy search threshold from 0.0 (exact) to 1.0 (match anything) (default: 0.4)
|
|
115
|
+
|
|
116
|
+
**Examples:**
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
# Basic search (fuzzy enabled by default)
|
|
120
|
+
→ search_docs({ library: "react", query: "useState" })
|
|
121
|
+
|
|
122
|
+
# Search with typos - still finds "useState"
|
|
123
|
+
→ search_docs({ library: "react", query: "useStat" })
|
|
124
|
+
|
|
125
|
+
# Exact match only (disable fuzzy)
|
|
126
|
+
→ search_docs({ library: "react", query: "useState", fuzzy: false })
|
|
127
|
+
|
|
128
|
+
# Stricter fuzzy matching
|
|
129
|
+
→ search_docs({ library: "react", query: "useState", threshold: 0.2 })
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `get_doc_page`
|
|
133
|
+
|
|
134
|
+
Get the content of a specific documentation page.
|
|
135
|
+
|
|
136
|
+
**Parameters:**
|
|
137
|
+
|
|
138
|
+
- `library` (required): Library slug
|
|
139
|
+
- `path` (required): Documentation path
|
|
140
|
+
|
|
141
|
+
**Example:**
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Get React useState documentation
|
|
145
|
+
→ get_doc_page({ library: "react", path: "reference/react/usestate" })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
### Run Tests
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bun test
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Run Linter
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
bun lint
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### CLI Test Script
|
|
163
|
+
|
|
164
|
+
A CLI script is provided for manual testing:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# List available docs (optionally filtered)
|
|
168
|
+
node scripts/test-cli.ts list react
|
|
169
|
+
|
|
170
|
+
# Search within a library
|
|
171
|
+
node scripts/test-cli.ts search react useState
|
|
172
|
+
|
|
173
|
+
# Get a specific doc page
|
|
174
|
+
node scripts/test-cli.ts get react reference/react/usestate
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Project Structure
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
devdocs-mcp/
|
|
181
|
+
├── src/
|
|
182
|
+
│ ├── index.ts # Entry point (stdio transport)
|
|
183
|
+
│ ├── server.ts # MCP Server configuration
|
|
184
|
+
│ ├── services/
|
|
185
|
+
│ │ ├── file-cache.ts # File-based cache with TTL
|
|
186
|
+
│ │ └── devdocs-client.ts # DevDocs API client
|
|
187
|
+
│ ├── tools/
|
|
188
|
+
│ │ └── index.ts # MCP tool implementations
|
|
189
|
+
│ ├── utils/
|
|
190
|
+
│ │ └── html-to-markdown.ts
|
|
191
|
+
│ └── types/
|
|
192
|
+
│ └── devdocs.ts
|
|
193
|
+
├── test/ # Test files (140 tests)
|
|
194
|
+
├── scripts/
|
|
195
|
+
│ └── test-cli.ts # CLI test script
|
|
196
|
+
├── cache/ # Cache directory (gitignored)
|
|
197
|
+
└── package.json
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Technical Details
|
|
201
|
+
|
|
202
|
+
### Dependencies
|
|
203
|
+
|
|
204
|
+
| Package | Version | Purpose |
|
|
205
|
+
| --------------------------- | ------- | --------------------------- |
|
|
206
|
+
| `@modelcontextprotocol/sdk` | ^1.25.3 | MCP protocol implementation |
|
|
207
|
+
| `zod` | ^3.25.0 | Schema validation |
|
|
208
|
+
| `turndown` | ^7.2.2 | HTML to Markdown conversion |
|
|
209
|
+
| `fuse.js` | ^7.1.0 | Fuzzy search |
|
|
210
|
+
|
|
211
|
+
### Cache TTL Settings
|
|
212
|
+
|
|
213
|
+
| Data Type | TTL |
|
|
214
|
+
| ------------------- | -------- |
|
|
215
|
+
| Documentation list | 24 hours |
|
|
216
|
+
| Documentation index | 12 hours |
|
|
217
|
+
| Documentation pages | 7 days |
|
|
218
|
+
|
|
219
|
+
### DevDocs API Endpoints
|
|
220
|
+
|
|
221
|
+
| Endpoint | Purpose |
|
|
222
|
+
| ------------------------------------------------- | ------------------ |
|
|
223
|
+
| `https://devdocs.io/docs.json` | All available docs |
|
|
224
|
+
| `https://devdocs.io/docs/{slug}/index.json` | Doc index |
|
|
225
|
+
| `https://documents.devdocs.io/{slug}/{path}.html` | Doc content |
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devdocs-mcp",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "MCP Server for fetching documentation from DevDocs",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"devdocs-mcp": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"bun": ">=1.0.0"
|
|
12
|
+
},
|
|
13
|
+
"files": ["src", "package.json", "README.md", "LICENSE"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "bun run src/index.ts",
|
|
16
|
+
"dev": "bun --watch src/index.ts",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"lint": "bunx @biomejs/biome check .",
|
|
19
|
+
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
20
|
+
"format": "bunx @biomejs/biome format --write .",
|
|
21
|
+
"format:check": "bunx @biomejs/biome format .",
|
|
22
|
+
"check": "bunx @biomejs/biome check --write . && bunx @biomejs/biome format --write .",
|
|
23
|
+
"check:ci": "bunx @biomejs/biome check . && bunx @biomejs/biome format .",
|
|
24
|
+
"typecheck": "bunx tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
28
|
+
"fuse.js": "^7.1.0",
|
|
29
|
+
"turndown": "^7.2.2",
|
|
30
|
+
"zod": "^3.25.76"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@biomejs/biome": "^1.9.4",
|
|
34
|
+
"@types/bun": "^1.1.15",
|
|
35
|
+
"@types/turndown": "^5.0.6",
|
|
36
|
+
"typescript": "^5.9.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
4
|
+
import { createServer } from "./server.ts"
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const server = createServer()
|
|
8
|
+
const transport = new StdioServerTransport()
|
|
9
|
+
|
|
10
|
+
await server.connect(transport)
|
|
11
|
+
|
|
12
|
+
// Log to stderr (stdout is used for MCP protocol)
|
|
13
|
+
console.error("DevDocs MCP Server started")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
main().catch(error => {
|
|
17
|
+
console.error("Fatal error:", error)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { DEFAULT_CACHE_DIR, FileCache } from "./services/file-cache.ts"
|
|
4
|
+
import { createTools } from "./tools/index.ts"
|
|
5
|
+
|
|
6
|
+
export interface ServerOptions {
|
|
7
|
+
cacheDir?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createServer(options?: ServerOptions): McpServer {
|
|
11
|
+
const cacheDir = options?.cacheDir ?? DEFAULT_CACHE_DIR
|
|
12
|
+
const cache = new FileCache({ cacheDir, defaultTtl: 24 * 60 * 60 * 1000 })
|
|
13
|
+
const tools = createTools(cache)
|
|
14
|
+
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: "devdocs-mcp",
|
|
17
|
+
version: "1.0.0",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Register tool: list_available_docs
|
|
21
|
+
server.registerTool(
|
|
22
|
+
"list_available_docs",
|
|
23
|
+
{
|
|
24
|
+
description:
|
|
25
|
+
"List all available documentation libraries from DevDocs. Use filter to search for specific libraries.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
filter: z.string().optional().describe("Filter libraries by name (e.g., 'react', 'python', 'typescript')"),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async ({ filter }) => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await tools.listAvailableDocs({ filter })
|
|
33
|
+
return { content: [{ type: "text", text: result }] }
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : "Unknown error"
|
|
36
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// Register tool: search_docs
|
|
42
|
+
server.registerTool(
|
|
43
|
+
"search_docs",
|
|
44
|
+
{
|
|
45
|
+
description:
|
|
46
|
+
"Search within a library's documentation. Returns matching entries with their paths. Supports fuzzy search for typo-tolerant matching.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
library: z.string().describe("Library slug (e.g., 'react', 'typescript', 'python~3.12')"),
|
|
49
|
+
query: z.string().describe("Search query (e.g., 'useState', 'async function')"),
|
|
50
|
+
limit: z.number().optional().default(10).describe("Maximum results to return (default: 10)"),
|
|
51
|
+
fuzzy: z
|
|
52
|
+
.boolean()
|
|
53
|
+
.optional()
|
|
54
|
+
.default(true)
|
|
55
|
+
.describe("Enable fuzzy search for typo-tolerant matching (default: true)"),
|
|
56
|
+
threshold: z
|
|
57
|
+
.number()
|
|
58
|
+
.optional()
|
|
59
|
+
.default(0.4)
|
|
60
|
+
.describe("Fuzzy search threshold: 0.0 = exact match, 1.0 = match anything (default: 0.4)"),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
async ({ library, query, limit, fuzzy, threshold }) => {
|
|
64
|
+
try {
|
|
65
|
+
const result = await tools.searchDocs({ library, query, limit, fuzzy, threshold })
|
|
66
|
+
return { content: [{ type: "text", text: result }] }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : "Unknown error"
|
|
69
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// Register tool: get_doc_page
|
|
75
|
+
server.registerTool(
|
|
76
|
+
"get_doc_page",
|
|
77
|
+
{
|
|
78
|
+
description: "Get the content of a specific documentation page. Returns the documentation in Markdown format.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
library: z.string().describe("Library slug (e.g., 'react', 'typescript')"),
|
|
81
|
+
path: z.string().describe("Documentation path (e.g., 'reference/react/usestate')"),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
async ({ library, path }) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await tools.getDocPage({ library, path })
|
|
87
|
+
return { content: [{ type: "text", text: result }] }
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : "Unknown error"
|
|
90
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return server
|
|
96
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import Fuse from "fuse.js"
|
|
2
|
+
import type { DevDocsDoc, DocEntry, DocIndex, ParsedSlug } from "../types/devdocs.ts"
|
|
3
|
+
import { htmlToMarkdown } from "../utils/html-to-markdown.ts"
|
|
4
|
+
import type { FileCache } from "./file-cache.ts"
|
|
5
|
+
|
|
6
|
+
export interface SearchOptions {
|
|
7
|
+
/** Enable fuzzy search (default: true) */
|
|
8
|
+
fuzzy?: boolean
|
|
9
|
+
/** Fuzzy search threshold (0.0 = exact match, 1.0 = match anything, default: 0.4) */
|
|
10
|
+
threshold?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchResult extends DocEntry {
|
|
14
|
+
/** Fuzzy search score (0 = perfect match, 1 = no match). Only present when fuzzy search is enabled. */
|
|
15
|
+
score?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DevDocsClientOptions {
|
|
19
|
+
cache: FileCache
|
|
20
|
+
baseUrl?: string
|
|
21
|
+
documentsUrl?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CACHE_KEYS = {
|
|
25
|
+
DOCS_LIST: "devdocs:docs-list",
|
|
26
|
+
DOC_INDEX: (slug: string) => `devdocs:index:${slug}`,
|
|
27
|
+
DOC_PAGE: (slug: string, path: string) => `devdocs:page:${slug}:${path}`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CACHE_TTL = {
|
|
31
|
+
DOCS_LIST: 24 * 60 * 60 * 1000, // 24 hours
|
|
32
|
+
DOC_INDEX: 12 * 60 * 60 * 1000, // 12 hours
|
|
33
|
+
DOC_PAGE: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class DevDocsClient {
|
|
37
|
+
private cache: FileCache
|
|
38
|
+
private baseUrl: string
|
|
39
|
+
private documentsUrl: string
|
|
40
|
+
|
|
41
|
+
constructor(options: DevDocsClientOptions) {
|
|
42
|
+
this.cache = options.cache
|
|
43
|
+
this.baseUrl = options.baseUrl ?? "https://devdocs.io"
|
|
44
|
+
this.documentsUrl = options.documentsUrl ?? "https://documents.devdocs.io"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch all available documentation libraries
|
|
49
|
+
*/
|
|
50
|
+
async listDocs(filter?: string): Promise<DevDocsDoc[]> {
|
|
51
|
+
const cacheKey = CACHE_KEYS.DOCS_LIST
|
|
52
|
+
|
|
53
|
+
// Try cache first
|
|
54
|
+
let docs = await this.cache.get<DevDocsDoc[]>(cacheKey)
|
|
55
|
+
|
|
56
|
+
if (!docs) {
|
|
57
|
+
const response = await fetch(`${this.baseUrl}/docs.json`)
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Failed to fetch docs list: ${response.status}`)
|
|
60
|
+
}
|
|
61
|
+
docs = (await response.json()) as DevDocsDoc[]
|
|
62
|
+
await this.cache.set(cacheKey, docs, { ttl: CACHE_TTL.DOCS_LIST })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Apply filter if provided
|
|
66
|
+
if (filter) {
|
|
67
|
+
const lowerFilter = filter.toLowerCase()
|
|
68
|
+
docs = docs.filter(
|
|
69
|
+
doc => doc.name.toLowerCase().includes(lowerFilter) || doc.slug.toLowerCase().includes(lowerFilter),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return docs
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get documentation index for a specific library
|
|
78
|
+
*/
|
|
79
|
+
async getDocIndex(slug: string): Promise<DocIndex> {
|
|
80
|
+
const cacheKey = CACHE_KEYS.DOC_INDEX(slug)
|
|
81
|
+
|
|
82
|
+
// Try cache first
|
|
83
|
+
let index = await this.cache.get<DocIndex>(cacheKey)
|
|
84
|
+
|
|
85
|
+
if (!index) {
|
|
86
|
+
const response = await fetch(`${this.baseUrl}/docs/${slug}/index.json`)
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Failed to fetch doc index for '${slug}': ${response.status}`)
|
|
89
|
+
}
|
|
90
|
+
index = (await response.json()) as DocIndex
|
|
91
|
+
await this.cache.set(cacheKey, index, { ttl: CACHE_TTL.DOC_INDEX })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return index
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Search within a library's documentation
|
|
99
|
+
* Supports both exact substring matching and fuzzy search
|
|
100
|
+
*/
|
|
101
|
+
async searchDocs(slug: string, query: string, limit = 10, options: SearchOptions = {}): Promise<SearchResult[]> {
|
|
102
|
+
const index = await this.getDocIndex(slug)
|
|
103
|
+
const { fuzzy = true, threshold = 0.4 } = options
|
|
104
|
+
|
|
105
|
+
// Handle empty query - return first N entries
|
|
106
|
+
if (!query || query.trim() === "") {
|
|
107
|
+
return index.entries.slice(0, limit).map(entry => ({ ...entry }))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (fuzzy) {
|
|
111
|
+
// Use Fuse.js for fuzzy search
|
|
112
|
+
const fuse = new Fuse(index.entries, {
|
|
113
|
+
keys: [
|
|
114
|
+
{ name: "name", weight: 0.7 },
|
|
115
|
+
{ name: "path", weight: 0.2 },
|
|
116
|
+
{ name: "type", weight: 0.1 },
|
|
117
|
+
],
|
|
118
|
+
threshold,
|
|
119
|
+
includeScore: true,
|
|
120
|
+
ignoreLocation: true,
|
|
121
|
+
minMatchCharLength: 1,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const fuseResults = fuse.search(query, { limit })
|
|
125
|
+
return fuseResults.map(result => ({
|
|
126
|
+
...result.item,
|
|
127
|
+
score: result.score,
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
// Fallback to exact substring matching
|
|
131
|
+
const lowerQuery = query.toLowerCase()
|
|
132
|
+
const results = index.entries.filter(entry => {
|
|
133
|
+
return entry.name.toLowerCase().includes(lowerQuery) || entry.path.toLowerCase().includes(lowerQuery)
|
|
134
|
+
})
|
|
135
|
+
return results.slice(0, limit)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get a specific documentation page content (as Markdown)
|
|
140
|
+
*/
|
|
141
|
+
async getDocPage(slug: string, path: string): Promise<string> {
|
|
142
|
+
// Strip anchor from path (e.g., "fs#fspromisesreadfilepath-options" -> "fs")
|
|
143
|
+
// DevDocs paths may contain anchors that should not be part of the URL
|
|
144
|
+
const basePath = path.split("#")[0]
|
|
145
|
+
const cacheKey = CACHE_KEYS.DOC_PAGE(slug, basePath)
|
|
146
|
+
|
|
147
|
+
// Try cache first
|
|
148
|
+
let markdown = await this.cache.get<string>(cacheKey)
|
|
149
|
+
|
|
150
|
+
if (!markdown) {
|
|
151
|
+
const url = `${this.documentsUrl}/${slug}/${basePath}.html`
|
|
152
|
+
const response = await fetch(url)
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new Error(`Failed to fetch doc page '${basePath}' for '${slug}': ${response.status}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const html = await response.text()
|
|
159
|
+
markdown = htmlToMarkdown(html)
|
|
160
|
+
await this.cache.set(cacheKey, markdown, { ttl: CACHE_TTL.DOC_PAGE })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return markdown
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse a library slug to extract name and version
|
|
168
|
+
* Examples:
|
|
169
|
+
* "react" -> { name: "react", version: undefined, slug: "react" }
|
|
170
|
+
* "react~18" -> { name: "react", version: "18", slug: "react~18" }
|
|
171
|
+
* "python~3.12" -> { name: "python", version: "3.12", slug: "python~3.12" }
|
|
172
|
+
*/
|
|
173
|
+
parseSlug(slug: string): ParsedSlug {
|
|
174
|
+
const parts = slug.split("~")
|
|
175
|
+
return {
|
|
176
|
+
name: parts[0],
|
|
177
|
+
version: parts.length > 1 ? parts.slice(1).join("~") : undefined,
|
|
178
|
+
slug,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export interface CacheOptions {
|
|
6
|
+
cacheDir: string
|
|
7
|
+
defaultTtl: number // milliseconds
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SetOptions {
|
|
11
|
+
ttl?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CacheEntry<T> {
|
|
15
|
+
data: T
|
|
16
|
+
expiresAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_CACHE_DIR = path.join(os.homedir(), ".mcp", "devdocs")
|
|
20
|
+
|
|
21
|
+
const DEFAULT_OPTIONS: CacheOptions = {
|
|
22
|
+
cacheDir: DEFAULT_CACHE_DIR,
|
|
23
|
+
defaultTtl: 24 * 60 * 60 * 1000, // 24 hours
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class FileCache {
|
|
27
|
+
private options: CacheOptions
|
|
28
|
+
|
|
29
|
+
constructor(options?: Partial<CacheOptions>) {
|
|
30
|
+
this.options = { ...DEFAULT_OPTIONS, ...options }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sanitize cache key to create a valid filename
|
|
35
|
+
*/
|
|
36
|
+
private sanitizeKey(key: string): string {
|
|
37
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the file path for a cache key
|
|
42
|
+
*/
|
|
43
|
+
private getFilePath(key: string): string {
|
|
44
|
+
return path.join(this.options.cacheDir, `${this.sanitizeKey(key)}.json`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get cached data by key
|
|
49
|
+
* Returns null if not found or expired
|
|
50
|
+
*/
|
|
51
|
+
async get<T>(key: string): Promise<T | null> {
|
|
52
|
+
const filePath = this.getFilePath(key)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
56
|
+
const entry: CacheEntry<T> = JSON.parse(content)
|
|
57
|
+
|
|
58
|
+
// Check if expired
|
|
59
|
+
if (Date.now() > entry.expiresAt) {
|
|
60
|
+
// Clean up expired entry
|
|
61
|
+
await this.delete(key)
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return entry.data
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set cached data with optional custom TTL
|
|
73
|
+
*/
|
|
74
|
+
async set<T>(key: string, data: T, options?: SetOptions): Promise<void> {
|
|
75
|
+
const ttl = options?.ttl ?? this.options.defaultTtl
|
|
76
|
+
const entry: CacheEntry<T> = {
|
|
77
|
+
data,
|
|
78
|
+
expiresAt: Date.now() + ttl,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const filePath = this.getFilePath(key)
|
|
82
|
+
|
|
83
|
+
// Ensure cache directory exists
|
|
84
|
+
await fs.mkdir(this.options.cacheDir, { recursive: true })
|
|
85
|
+
|
|
86
|
+
await fs.writeFile(filePath, JSON.stringify(entry), "utf-8")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Delete a specific cache entry
|
|
91
|
+
*/
|
|
92
|
+
async delete(key: string): Promise<void> {
|
|
93
|
+
const filePath = this.getFilePath(key)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await fs.unlink(filePath)
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore errors (file may not exist)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear all cache entries
|
|
104
|
+
*/
|
|
105
|
+
async clear(): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const files = await fs.readdir(this.options.cacheDir)
|
|
108
|
+
await Promise.all(files.map(file => fs.unlink(path.join(this.options.cacheDir, file))))
|
|
109
|
+
} catch {
|
|
110
|
+
// Ignore errors (directory may not exist)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a valid (non-expired) cache entry exists
|
|
116
|
+
*/
|
|
117
|
+
async has(key: string): Promise<boolean> {
|
|
118
|
+
const data = await this.get(key)
|
|
119
|
+
return data !== null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DevDocsClient } from "../services/devdocs-client.ts"
|
|
2
|
+
import type { SearchOptions } from "../services/devdocs-client.ts"
|
|
3
|
+
import type { FileCache } from "../services/file-cache.ts"
|
|
4
|
+
|
|
5
|
+
export interface ListAvailableDocsInput {
|
|
6
|
+
filter?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchDocsInput {
|
|
10
|
+
library: string
|
|
11
|
+
query: string
|
|
12
|
+
limit?: number
|
|
13
|
+
fuzzy?: boolean
|
|
14
|
+
threshold?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GetDocPageInput {
|
|
18
|
+
library: string
|
|
19
|
+
path: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Tools {
|
|
23
|
+
listAvailableDocs: (input: ListAvailableDocsInput) => Promise<string>
|
|
24
|
+
searchDocs: (input: SearchDocsInput) => Promise<string>
|
|
25
|
+
getDocPage: (input: GetDocPageInput) => Promise<string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createTools(cache: FileCache): Tools {
|
|
29
|
+
const client = new DevDocsClient({ cache })
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
/**
|
|
33
|
+
* List all available documentation libraries
|
|
34
|
+
*/
|
|
35
|
+
async listAvailableDocs(input: ListAvailableDocsInput): Promise<string> {
|
|
36
|
+
const docs = await client.listDocs(input.filter)
|
|
37
|
+
|
|
38
|
+
if (docs.length === 0) {
|
|
39
|
+
return input.filter ? `No documentation found matching "${input.filter}".` : "No documentation available."
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = docs.map(doc => {
|
|
43
|
+
const version = doc.version ? ` (${doc.version})` : ""
|
|
44
|
+
return `- **${doc.name}**${version}: \`${doc.slug}\``
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const header = input.filter
|
|
48
|
+
? `Found ${docs.length} documentation(s) matching "${input.filter}":\n\n`
|
|
49
|
+
: `Available documentation (${docs.length}):\n\n`
|
|
50
|
+
|
|
51
|
+
return header + lines.join("\n")
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Search within a library's documentation
|
|
56
|
+
*/
|
|
57
|
+
async searchDocs(input: SearchDocsInput): Promise<string> {
|
|
58
|
+
const options: SearchOptions = {
|
|
59
|
+
fuzzy: input.fuzzy,
|
|
60
|
+
threshold: input.threshold,
|
|
61
|
+
}
|
|
62
|
+
const results = await client.searchDocs(input.library, input.query, input.limit ?? 10, options)
|
|
63
|
+
|
|
64
|
+
if (results.length === 0) {
|
|
65
|
+
return `No results found for "${input.query}" in ${input.library} documentation.`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = results.map((entry, index) => {
|
|
69
|
+
const scoreInfo = entry.score !== undefined ? ` (score: ${(1 - entry.score).toFixed(2)})` : ""
|
|
70
|
+
return `${index + 1}. **${entry.name}**${scoreInfo}\n - Path: \`${entry.path}\`\n - Type: ${entry.type}`
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const searchMode = input.fuzzy !== false ? "fuzzy" : "exact"
|
|
74
|
+
return `Found ${results.length} result(s) for "${input.query}" in ${input.library} (${searchMode} search):\n\n${lines.join("\n\n")}`
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get a specific documentation page
|
|
79
|
+
*/
|
|
80
|
+
async getDocPage(input: GetDocPageInput): Promise<string> {
|
|
81
|
+
const content = await client.getDocPage(input.library, input.path)
|
|
82
|
+
return content
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevDocs API Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Documentation entry from DevDocs docs.json
|
|
7
|
+
*/
|
|
8
|
+
export interface DevDocsDoc {
|
|
9
|
+
name: string
|
|
10
|
+
slug: string
|
|
11
|
+
type: string
|
|
12
|
+
version?: string
|
|
13
|
+
release?: string
|
|
14
|
+
mtime: number
|
|
15
|
+
db_size: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Entry in a documentation index
|
|
20
|
+
*/
|
|
21
|
+
export interface DocEntry {
|
|
22
|
+
name: string
|
|
23
|
+
path: string
|
|
24
|
+
type: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Documentation type/category
|
|
29
|
+
*/
|
|
30
|
+
export interface DocType {
|
|
31
|
+
name: string
|
|
32
|
+
count: number
|
|
33
|
+
slug: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Documentation index response
|
|
38
|
+
*/
|
|
39
|
+
export interface DocIndex {
|
|
40
|
+
entries: DocEntry[]
|
|
41
|
+
types: DocType[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parsed library slug
|
|
46
|
+
*/
|
|
47
|
+
export interface ParsedSlug {
|
|
48
|
+
name: string
|
|
49
|
+
version?: string
|
|
50
|
+
slug: string
|
|
51
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import TurndownService from "turndown"
|
|
2
|
+
|
|
3
|
+
// Create and configure turndown instance
|
|
4
|
+
const turndown = new TurndownService({
|
|
5
|
+
headingStyle: "atx",
|
|
6
|
+
codeBlockStyle: "fenced",
|
|
7
|
+
bulletListMarker: "-",
|
|
8
|
+
emDelimiter: "*",
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// Custom rule for code blocks with language detection
|
|
12
|
+
turndown.addRule("fencedCodeBlock", {
|
|
13
|
+
filter: (node): boolean => {
|
|
14
|
+
return node.nodeName === "PRE" && node.querySelector("code") !== null
|
|
15
|
+
},
|
|
16
|
+
replacement: (_content, node): string => {
|
|
17
|
+
const element = node as unknown as {
|
|
18
|
+
querySelector: (
|
|
19
|
+
s: string,
|
|
20
|
+
) => { getAttribute: (a: string) => string | null; className?: string; textContent: string | null } | null
|
|
21
|
+
getAttribute: (a: string) => string | null
|
|
22
|
+
}
|
|
23
|
+
const codeElement = element.querySelector("code")
|
|
24
|
+
if (!codeElement) return ""
|
|
25
|
+
|
|
26
|
+
// Try to detect language from various attributes
|
|
27
|
+
const lang =
|
|
28
|
+
element.getAttribute("data-language") ||
|
|
29
|
+
codeElement.getAttribute("data-language") ||
|
|
30
|
+
codeElement.className?.match(/language-(\w+)/)?.[1] ||
|
|
31
|
+
""
|
|
32
|
+
|
|
33
|
+
const code = codeElement.textContent || ""
|
|
34
|
+
return `\n\`\`\`${lang}\n${code.trim()}\n\`\`\`\n`
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Remove script tags
|
|
39
|
+
turndown.addRule("removeScripts", {
|
|
40
|
+
filter: ["script"],
|
|
41
|
+
replacement: () => "",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Remove style tags
|
|
45
|
+
turndown.addRule("removeStyles", {
|
|
46
|
+
filter: ["style"],
|
|
47
|
+
replacement: () => "",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Remove SVG elements (often used for icons in docs)
|
|
51
|
+
turndown.addRule("removeSvg", {
|
|
52
|
+
filter: ["svg"],
|
|
53
|
+
replacement: () => "",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Remove navigation elements
|
|
57
|
+
turndown.addRule("removeNav", {
|
|
58
|
+
filter: ["nav"],
|
|
59
|
+
replacement: () => "",
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convert HTML to Markdown
|
|
64
|
+
* Optimized for DevDocs HTML structure
|
|
65
|
+
*/
|
|
66
|
+
export function htmlToMarkdown(html: string): string {
|
|
67
|
+
if (!html?.trim()) {
|
|
68
|
+
return ""
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const markdown = turndown.turndown(html)
|
|
73
|
+
// Clean up excessive newlines
|
|
74
|
+
return markdown.replace(/\n{3,}/g, "\n\n").trim()
|
|
75
|
+
} catch {
|
|
76
|
+
// If conversion fails, return empty string
|
|
77
|
+
return ""
|
|
78
|
+
}
|
|
79
|
+
}
|