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 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
+ }