react-native-docs-mcp 0.1.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 +133 -0
- package/dist/index.js +958 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="../../poster.png" width="100%" alt="React Native Docs MCP">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# React Native Docs MCP Server
|
|
6
|
+
|
|
7
|
+
AI-powered semantic search over React Native documentation for Claude, Cursor, and other MCP clients.
|
|
8
|
+
|
|
9
|
+
Looking for React docs instead? See [react-docs-mcp](https://www.npmjs.com/package/react-docs-mcp).
|
|
10
|
+
|
|
11
|
+
## 🚀 Installation (One Command)
|
|
12
|
+
|
|
13
|
+
### Claude Code
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
claude mcp add --transport stdio react-native-docs -- npx react-native-docs-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Claude Desktop
|
|
20
|
+
|
|
21
|
+
Edit: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"react-native-docs": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "react-native-docs-mcp"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Cursor
|
|
35
|
+
|
|
36
|
+
**Settings** → **Cursor settings** → **Tools and MCP** → Add server:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"react-native-docs": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "react-native-docs-mcp"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**That's it!** Restart your editor and ask about React Native.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- **🔍 Semantic Search**: AI-powered search using embeddings for conceptual matches
|
|
56
|
+
- **⚡ Fast Results**: In-memory vector search with hybrid keyword+semantic ranking
|
|
57
|
+
- **📦 Zero Config**: Works with `npx` - no installation needed
|
|
58
|
+
- **🤖 Local AI**: Runs embeddings locally (no API costs)
|
|
59
|
+
- **📝 Concise Responses**: Returns summaries instead of full documentation
|
|
60
|
+
- **🔄 Auto-sync**: Pulls latest docs from the react-native-website repo automatically
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Once configured, the server provides the following capabilities to AI agents:
|
|
65
|
+
|
|
66
|
+
### Tools
|
|
67
|
+
|
|
68
|
+
#### `search_react_native_docs`
|
|
69
|
+
|
|
70
|
+
Search across React Native documentation.
|
|
71
|
+
|
|
72
|
+
**Parameters**:
|
|
73
|
+
|
|
74
|
+
- `query` (required): Search query string
|
|
75
|
+
- `section` (optional): Filter by section (the-new-architecture, legacy, releases)
|
|
76
|
+
- `limit` (optional): Maximum number of results (default: 10, max: 50)
|
|
77
|
+
|
|
78
|
+
**Example**:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Search for "flexbox layout" in React Native docs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### `get_doc`
|
|
85
|
+
|
|
86
|
+
Get a specific documentation page.
|
|
87
|
+
|
|
88
|
+
**Parameters**:
|
|
89
|
+
|
|
90
|
+
- `path` (required): Document path (e.g., "getting-started", "the-new-architecture/using-codegen")
|
|
91
|
+
|
|
92
|
+
**Example**:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
Get the React Native flexbox documentation
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### `list_sections`
|
|
99
|
+
|
|
100
|
+
List all available documentation sections.
|
|
101
|
+
|
|
102
|
+
#### `update_docs`
|
|
103
|
+
|
|
104
|
+
Pull latest documentation from the Git repository.
|
|
105
|
+
|
|
106
|
+
### Resources
|
|
107
|
+
|
|
108
|
+
The server exposes documentation as resources with the URI pattern:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
react-native-docs://{section}/{path}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Limitations
|
|
115
|
+
|
|
116
|
+
- **Docs source**: content is cloned from the unversioned `docs/` folder in [facebook/react-native-website](https://github.com/facebook/react-native-website), which is upstream's live editing source. It may occasionally be a few days ahead of the latest published release rather than pinned to a specific React Native version.
|
|
117
|
+
- **Sections**: most React Native docs pages live flat at the root of `docs/` with no meaningful section — only `the-new-architecture`, `legacy`, and `releases` are real subfolders. The `section` filter reliably narrows results only for those three; for everything else, search unfiltered.
|
|
118
|
+
- **Blog posts**: `website/blog/` is not indexed yet.
|
|
119
|
+
- **MDX rendering**: `.mdx`-only syntax (JSX component imports, admonitions) is stripped as best-effort plain text for search indexing, so snippets/summaries for some pages may include stray import lines.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
This package shares its engine (`src/`) with the root [react-docs-mcp](../../) project in this repo — see that project's README for the underlying architecture. This package's own source only configures the shared engine with React Native-specific defaults (`src/index.ts`) and is bundled standalone with [tsup](https://tsup.egoist.dev/).
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm install
|
|
127
|
+
npm run build
|
|
128
|
+
npm run dev # run directly with tsx, no build step
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT. React Native documentation content is © Meta Platforms, Inc. and licensed separately by the [react-native-website](https://github.com/facebook/react-native-website) project.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../../src/config.ts
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
var getCacheDir = (cacheDirName) => {
|
|
7
|
+
const platform = process.platform;
|
|
8
|
+
const home = homedir();
|
|
9
|
+
if (platform === "darwin" || platform === "linux") {
|
|
10
|
+
return join(home, ".cache", cacheDirName);
|
|
11
|
+
} else if (platform === "win32") {
|
|
12
|
+
return join(process.env.LOCALAPPDATA || join(home, "AppData", "Local"), cacheDirName);
|
|
13
|
+
}
|
|
14
|
+
return join(home, `.${cacheDirName}`);
|
|
15
|
+
};
|
|
16
|
+
function resolve(preset) {
|
|
17
|
+
const { cacheDirName, repoFolderName, repo, ...rest } = preset;
|
|
18
|
+
return {
|
|
19
|
+
...rest,
|
|
20
|
+
repo: {
|
|
21
|
+
url: repo.url,
|
|
22
|
+
contentPath: repo.contentPath,
|
|
23
|
+
localPath: join(getCacheDir(cacheDirName), repoFolderName)
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
var defaultPreset = {
|
|
28
|
+
cacheDirName: "react-docs-mcp",
|
|
29
|
+
repoFolderName: "react-dev-repo",
|
|
30
|
+
repo: {
|
|
31
|
+
url: "https://github.com/reactjs/react.dev.git",
|
|
32
|
+
contentPath: "src/content"
|
|
33
|
+
},
|
|
34
|
+
search: {
|
|
35
|
+
defaultLimit: 10,
|
|
36
|
+
maxLimit: 50,
|
|
37
|
+
minScore: 0.1,
|
|
38
|
+
semanticSearchEnabled: true,
|
|
39
|
+
semanticMinSimilarity: 0.3,
|
|
40
|
+
hybridKeywordWeight: 0.3,
|
|
41
|
+
hybridSemanticWeight: 0.7
|
|
42
|
+
},
|
|
43
|
+
server: {
|
|
44
|
+
name: "react-docs-mcp",
|
|
45
|
+
version: "1.0.0"
|
|
46
|
+
},
|
|
47
|
+
sections: ["learn", "reference", "blog", "community"],
|
|
48
|
+
resourceUriScheme: "react-docs",
|
|
49
|
+
docsLabel: "React",
|
|
50
|
+
searchToolName: "search_react_docs",
|
|
51
|
+
searchToolDescription: "Search across React documentation. Returns relevant documentation pages with snippets.",
|
|
52
|
+
docUrl: { base: "https://react.dev", useFrontmatterId: false }
|
|
53
|
+
};
|
|
54
|
+
var activeConfig = resolve(defaultPreset);
|
|
55
|
+
function configure(preset) {
|
|
56
|
+
activeConfig = resolve(preset);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ../../src/presets/reactNativeDocs.ts
|
|
60
|
+
var reactNativeDocsPreset = {
|
|
61
|
+
cacheDirName: "react-native-docs-mcp",
|
|
62
|
+
repoFolderName: "react-native-website-repo",
|
|
63
|
+
repo: {
|
|
64
|
+
url: "https://github.com/facebook/react-native-website.git",
|
|
65
|
+
contentPath: "docs"
|
|
66
|
+
},
|
|
67
|
+
search: {
|
|
68
|
+
defaultLimit: 10,
|
|
69
|
+
maxLimit: 50,
|
|
70
|
+
minScore: 0.1,
|
|
71
|
+
semanticSearchEnabled: true,
|
|
72
|
+
semanticMinSimilarity: 0.3,
|
|
73
|
+
hybridKeywordWeight: 0.3,
|
|
74
|
+
hybridSemanticWeight: 0.7
|
|
75
|
+
},
|
|
76
|
+
server: {
|
|
77
|
+
name: "react-native-docs-mcp",
|
|
78
|
+
version: "0.1.0"
|
|
79
|
+
},
|
|
80
|
+
sections: ["the-new-architecture", "legacy", "releases"],
|
|
81
|
+
resourceUriScheme: "react-native-docs",
|
|
82
|
+
docsLabel: "React Native",
|
|
83
|
+
searchToolName: "search_react_native_docs",
|
|
84
|
+
searchToolDescription: "Search across React Native documentation. Returns relevant documentation pages with snippets.",
|
|
85
|
+
docUrl: { base: "https://reactnative.dev/docs", useFrontmatterId: true }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ../../src/server.ts
|
|
89
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
90
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
91
|
+
import {
|
|
92
|
+
CallToolRequestSchema,
|
|
93
|
+
ListResourcesRequestSchema,
|
|
94
|
+
ListToolsRequestSchema,
|
|
95
|
+
ReadResourceRequestSchema
|
|
96
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
97
|
+
import { z } from "zod";
|
|
98
|
+
|
|
99
|
+
// ../../src/docsManager.ts
|
|
100
|
+
import { simpleGit } from "simple-git";
|
|
101
|
+
import { promises as fs } from "fs";
|
|
102
|
+
import path from "path";
|
|
103
|
+
import fg from "fast-glob";
|
|
104
|
+
var DocsManager = class {
|
|
105
|
+
git;
|
|
106
|
+
repoPath;
|
|
107
|
+
contentPath;
|
|
108
|
+
fileCache = /* @__PURE__ */ new Map();
|
|
109
|
+
constructor() {
|
|
110
|
+
this.repoPath = path.resolve(activeConfig.repo.localPath);
|
|
111
|
+
this.contentPath = path.join(this.repoPath, activeConfig.repo.contentPath);
|
|
112
|
+
this.git = simpleGit();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Initialize the docs manager
|
|
116
|
+
* Checks if repo exists, clones if needed
|
|
117
|
+
*/
|
|
118
|
+
async initialize() {
|
|
119
|
+
const repoExists = await this.checkRepoExists();
|
|
120
|
+
if (!repoExists) {
|
|
121
|
+
console.log("Cloning React documentation repository...");
|
|
122
|
+
await this.cloneRepo();
|
|
123
|
+
console.log("Repository cloned successfully");
|
|
124
|
+
} else {
|
|
125
|
+
console.log("Repository already exists");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if repository exists locally
|
|
130
|
+
*/
|
|
131
|
+
async checkRepoExists() {
|
|
132
|
+
try {
|
|
133
|
+
await fs.access(path.join(this.repoPath, ".git"));
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Clone the repository
|
|
141
|
+
*/
|
|
142
|
+
async cloneRepo() {
|
|
143
|
+
try {
|
|
144
|
+
await fs.mkdir(path.dirname(this.repoPath), { recursive: true });
|
|
145
|
+
await this.git.clone(activeConfig.repo.url, this.repoPath, {
|
|
146
|
+
"--depth": 1
|
|
147
|
+
// Shallow clone for faster download
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Failed to clone repository: ${error instanceof Error ? error.message : String(error)}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get repository status
|
|
157
|
+
*/
|
|
158
|
+
async getStatus() {
|
|
159
|
+
const isCloned = await this.checkRepoExists();
|
|
160
|
+
if (!isCloned) {
|
|
161
|
+
return { isCloned: false };
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const git = simpleGit(this.repoPath);
|
|
165
|
+
const log = await git.log({ maxCount: 1 });
|
|
166
|
+
return {
|
|
167
|
+
isCloned: true,
|
|
168
|
+
currentCommit: log.latest?.hash,
|
|
169
|
+
lastUpdated: log.latest?.date ? new Date(log.latest.date) : void 0
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error("Failed to get repo status:", error);
|
|
173
|
+
return { isCloned: true };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Update repository (git pull)
|
|
178
|
+
* Returns true if updates were pulled
|
|
179
|
+
*/
|
|
180
|
+
async updateRepo() {
|
|
181
|
+
const isCloned = await this.checkRepoExists();
|
|
182
|
+
if (!isCloned) {
|
|
183
|
+
throw new Error("Repository not cloned. Call initialize() first.");
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const git = simpleGit(this.repoPath);
|
|
187
|
+
const beforeHash = await git.revparse(["HEAD"]);
|
|
188
|
+
await git.pull();
|
|
189
|
+
const afterHash = await git.revparse(["HEAD"]);
|
|
190
|
+
const hasUpdates = beforeHash !== afterHash;
|
|
191
|
+
if (hasUpdates) {
|
|
192
|
+
this.fileCache.clear();
|
|
193
|
+
console.log("Repository updated successfully");
|
|
194
|
+
} else {
|
|
195
|
+
console.log("Repository already up to date");
|
|
196
|
+
}
|
|
197
|
+
return hasUpdates;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Failed to update repository: ${error instanceof Error ? error.message : String(error)}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get all markdown files from a section
|
|
206
|
+
* @param section - Section name (learn, reference, etc.)
|
|
207
|
+
* @returns Array of file paths relative to content root
|
|
208
|
+
*/
|
|
209
|
+
async getDocsInSection(section) {
|
|
210
|
+
const cacheKey = `section:${section}`;
|
|
211
|
+
if (this.fileCache.has(cacheKey)) {
|
|
212
|
+
return this.fileCache.get(cacheKey);
|
|
213
|
+
}
|
|
214
|
+
const sectionPath = path.join(this.contentPath, section);
|
|
215
|
+
try {
|
|
216
|
+
await fs.access(sectionPath);
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const files = await fg(["**/*.md", "**/*.mdx"], {
|
|
221
|
+
cwd: sectionPath,
|
|
222
|
+
absolute: false,
|
|
223
|
+
ignore: ["**/_*.md", "**/_*.mdx"]
|
|
224
|
+
});
|
|
225
|
+
const relativePaths = files.map((file) => `${section}/${file}`);
|
|
226
|
+
this.fileCache.set(cacheKey, relativePaths);
|
|
227
|
+
return relativePaths;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get all markdown files across all sections
|
|
231
|
+
* @returns Array of file paths relative to content root
|
|
232
|
+
*/
|
|
233
|
+
async getAllDocs() {
|
|
234
|
+
const cacheKey = "all";
|
|
235
|
+
if (this.fileCache.has(cacheKey)) {
|
|
236
|
+
return this.fileCache.get(cacheKey);
|
|
237
|
+
}
|
|
238
|
+
const files = await fg(["**/*.md", "**/*.mdx"], {
|
|
239
|
+
cwd: this.contentPath,
|
|
240
|
+
absolute: false,
|
|
241
|
+
ignore: ["**/_*.md", "**/_*.mdx"]
|
|
242
|
+
});
|
|
243
|
+
this.fileCache.set(cacheKey, files);
|
|
244
|
+
return files;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Read file content
|
|
248
|
+
* @param relativePath - Path relative to content root
|
|
249
|
+
* @returns Raw file content
|
|
250
|
+
*/
|
|
251
|
+
async readDoc(relativePath) {
|
|
252
|
+
const fullPath = path.join(this.contentPath, relativePath);
|
|
253
|
+
try {
|
|
254
|
+
return await fs.readFile(fullPath, "utf-8");
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Failed to read document at ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Check if file exists
|
|
263
|
+
* @param relativePath - Path relative to content root
|
|
264
|
+
*/
|
|
265
|
+
async docExists(relativePath) {
|
|
266
|
+
const fullPath = path.join(this.contentPath, relativePath);
|
|
267
|
+
try {
|
|
268
|
+
await fs.access(fullPath);
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ../../src/markdownParser.ts
|
|
277
|
+
import matter from "gray-matter";
|
|
278
|
+
import { remark } from "remark";
|
|
279
|
+
import stripMarkdown from "strip-markdown";
|
|
280
|
+
async function parseMarkdown(content, path2) {
|
|
281
|
+
const { data, content: markdownContent } = matter(content);
|
|
282
|
+
const metadata = {
|
|
283
|
+
title: data.title || extractTitleFromPath(path2),
|
|
284
|
+
description: data.description,
|
|
285
|
+
date: data.date,
|
|
286
|
+
author: data.author,
|
|
287
|
+
tags: data.tags,
|
|
288
|
+
...data
|
|
289
|
+
};
|
|
290
|
+
const plainText = await markdownToPlainText(markdownContent);
|
|
291
|
+
const section = extractSection(path2);
|
|
292
|
+
return {
|
|
293
|
+
path: normalizePath(path2),
|
|
294
|
+
section,
|
|
295
|
+
metadata,
|
|
296
|
+
content: markdownContent,
|
|
297
|
+
plainText
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function markdownToPlainText(markdown) {
|
|
301
|
+
const result = await remark().use(stripMarkdown).process(markdown);
|
|
302
|
+
return String(result).replace(/\s+/g, " ").trim();
|
|
303
|
+
}
|
|
304
|
+
function extractSection(path2) {
|
|
305
|
+
const normalized = normalizePath(path2);
|
|
306
|
+
const parts = normalized.split("/");
|
|
307
|
+
return parts[0] || "unknown";
|
|
308
|
+
}
|
|
309
|
+
function normalizePath(filePath) {
|
|
310
|
+
return filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\.mdx?$/, "");
|
|
311
|
+
}
|
|
312
|
+
function extractTitleFromPath(filePath) {
|
|
313
|
+
const normalized = normalizePath(filePath);
|
|
314
|
+
const parts = normalized.split("/");
|
|
315
|
+
const filename = parts[parts.length - 1] || "Untitled";
|
|
316
|
+
return filename.replace(/[-_]/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ../../src/embeddingService.ts
|
|
320
|
+
import { pipeline, env } from "@xenova/transformers";
|
|
321
|
+
env.allowLocalModels = true;
|
|
322
|
+
env.allowRemoteModels = true;
|
|
323
|
+
var EmbeddingService = class {
|
|
324
|
+
pipeline = null;
|
|
325
|
+
initialized = false;
|
|
326
|
+
modelName = "Xenova/all-MiniLM-L6-v2";
|
|
327
|
+
/**
|
|
328
|
+
* Initialize the embedding pipeline
|
|
329
|
+
* Downloads model on first run (~23MB)
|
|
330
|
+
*/
|
|
331
|
+
async initialize() {
|
|
332
|
+
if (this.initialized) return;
|
|
333
|
+
console.log("Initializing embedding model (first run may take a moment)...");
|
|
334
|
+
try {
|
|
335
|
+
this.pipeline = await pipeline("feature-extraction", this.modelName);
|
|
336
|
+
this.initialized = true;
|
|
337
|
+
console.log("Embedding model initialized successfully");
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error("Failed to initialize embedding model:", error);
|
|
340
|
+
throw new Error(`Embedding initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Generate embedding for a text
|
|
345
|
+
* @param text - Text to embed
|
|
346
|
+
* @returns Vector embedding (384 dimensions for all-MiniLM-L6-v2)
|
|
347
|
+
*/
|
|
348
|
+
async generateEmbedding(text) {
|
|
349
|
+
if (!this.initialized) {
|
|
350
|
+
await this.initialize();
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const truncated = text.slice(0, 2e3);
|
|
354
|
+
const output = await this.pipeline(truncated, {
|
|
355
|
+
pooling: "mean",
|
|
356
|
+
normalize: true
|
|
357
|
+
});
|
|
358
|
+
const embedding = Array.from(output.data);
|
|
359
|
+
return embedding;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error("Failed to generate embedding:", error);
|
|
362
|
+
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Generate embeddings for multiple texts (batch processing)
|
|
367
|
+
* @param texts - Array of texts to embed
|
|
368
|
+
* @returns Array of vector embeddings
|
|
369
|
+
*/
|
|
370
|
+
async generateEmbeddings(texts) {
|
|
371
|
+
const embeddings = [];
|
|
372
|
+
for (const text of texts) {
|
|
373
|
+
const embedding = await this.generateEmbedding(text);
|
|
374
|
+
embeddings.push(embedding);
|
|
375
|
+
}
|
|
376
|
+
return embeddings;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Calculate cosine similarity between two vectors
|
|
380
|
+
* @param a - First vector
|
|
381
|
+
* @param b - Second vector
|
|
382
|
+
* @returns Similarity score (0-1, higher is more similar)
|
|
383
|
+
*/
|
|
384
|
+
cosineSimilarity(a, b) {
|
|
385
|
+
if (a.length !== b.length) {
|
|
386
|
+
throw new Error("Vectors must have same length");
|
|
387
|
+
}
|
|
388
|
+
let dotProduct = 0;
|
|
389
|
+
let normA = 0;
|
|
390
|
+
let normB = 0;
|
|
391
|
+
for (let i = 0; i < a.length; i++) {
|
|
392
|
+
dotProduct += a[i] * b[i];
|
|
393
|
+
normA += a[i] * a[i];
|
|
394
|
+
normB += b[i] * b[i];
|
|
395
|
+
}
|
|
396
|
+
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
|
397
|
+
if (magnitude === 0) return 0;
|
|
398
|
+
return dotProduct / magnitude;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Find most similar vectors to a query vector
|
|
402
|
+
* @param queryEmbedding - Query vector
|
|
403
|
+
* @param docEmbeddings - Array of document vectors with metadata
|
|
404
|
+
* @param topK - Number of results to return
|
|
405
|
+
* @returns Array of {index, similarity} sorted by similarity desc
|
|
406
|
+
*/
|
|
407
|
+
findMostSimilar(queryEmbedding, docEmbeddings, topK) {
|
|
408
|
+
const similarities = docEmbeddings.map(({ embedding, index }) => ({
|
|
409
|
+
index,
|
|
410
|
+
similarity: this.cosineSimilarity(queryEmbedding, embedding)
|
|
411
|
+
}));
|
|
412
|
+
similarities.sort((a, b) => b.similarity - a.similarity);
|
|
413
|
+
return similarities.slice(0, topK);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// ../../src/searchEngine.ts
|
|
418
|
+
var SearchEngine = class {
|
|
419
|
+
docsManager;
|
|
420
|
+
embeddingService;
|
|
421
|
+
documentIndex = /* @__PURE__ */ new Map();
|
|
422
|
+
indexed = false;
|
|
423
|
+
embeddingsGenerated = false;
|
|
424
|
+
/**
|
|
425
|
+
* Initialize search engine
|
|
426
|
+
* @param docsManager - Instance of DocsManager
|
|
427
|
+
*/
|
|
428
|
+
constructor(docsManager) {
|
|
429
|
+
this.docsManager = docsManager;
|
|
430
|
+
this.embeddingService = new EmbeddingService();
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Index all documents for searching
|
|
434
|
+
* Should be called after repo update
|
|
435
|
+
*/
|
|
436
|
+
async indexDocuments() {
|
|
437
|
+
console.log("Indexing documents...");
|
|
438
|
+
this.documentIndex.clear();
|
|
439
|
+
const allDocs = await this.docsManager.getAllDocs();
|
|
440
|
+
for (const docPath of allDocs) {
|
|
441
|
+
try {
|
|
442
|
+
const content = await this.docsManager.readDoc(docPath);
|
|
443
|
+
const parsedDoc = await parseMarkdown(content, docPath);
|
|
444
|
+
this.documentIndex.set(parsedDoc.path, parsedDoc);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
console.warn(`Failed to index document ${docPath}:`, error);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
this.indexed = true;
|
|
450
|
+
console.log(`Indexed ${this.documentIndex.size} documents`);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Generate embeddings for all documents
|
|
454
|
+
* Called lazily when semantic search is first used
|
|
455
|
+
*/
|
|
456
|
+
async generateEmbeddings() {
|
|
457
|
+
if (this.embeddingsGenerated) return;
|
|
458
|
+
console.log("Generating embeddings for documents (first run may take 1-2 minutes)...");
|
|
459
|
+
try {
|
|
460
|
+
await this.embeddingService.initialize();
|
|
461
|
+
let count = 0;
|
|
462
|
+
for (const doc of this.documentIndex.values()) {
|
|
463
|
+
const embeddingText = `${doc.metadata.title}. ${doc.metadata.description || ""}. ${doc.plainText.slice(0, 1e3)}`;
|
|
464
|
+
const embedding = await this.embeddingService.generateEmbedding(embeddingText);
|
|
465
|
+
doc.embedding = embedding;
|
|
466
|
+
count++;
|
|
467
|
+
if (count % 10 === 0) {
|
|
468
|
+
console.log(`Generated embeddings for ${count}/${this.documentIndex.size} documents...`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
this.embeddingsGenerated = true;
|
|
472
|
+
console.log(`Embeddings generated for all ${this.documentIndex.size} documents`);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error("Failed to generate embeddings:", error);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Search documents
|
|
480
|
+
* @param query - Search query string
|
|
481
|
+
* @param options - Search options (section filter, limit, etc.)
|
|
482
|
+
* @returns Ranked search results
|
|
483
|
+
*/
|
|
484
|
+
async search(query, options) {
|
|
485
|
+
if (!this.indexed) {
|
|
486
|
+
await this.indexDocuments();
|
|
487
|
+
}
|
|
488
|
+
if (!query.trim()) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const useSemanticSearch = options?.useSemanticSearch ?? activeConfig.search.semanticSearchEnabled;
|
|
492
|
+
if (useSemanticSearch) {
|
|
493
|
+
return await this.semanticSearch(query, options);
|
|
494
|
+
}
|
|
495
|
+
return await this.keywordSearch(query, options);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Keyword-based search
|
|
499
|
+
*/
|
|
500
|
+
async keywordSearch(query, options) {
|
|
501
|
+
const limit = Math.min(
|
|
502
|
+
options?.limit || activeConfig.search.defaultLimit,
|
|
503
|
+
activeConfig.search.maxLimit
|
|
504
|
+
);
|
|
505
|
+
const minScore = options?.minScore ?? activeConfig.search.minScore;
|
|
506
|
+
const sectionFilter = options?.section?.toLowerCase();
|
|
507
|
+
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
508
|
+
const results = [];
|
|
509
|
+
for (const doc of this.documentIndex.values()) {
|
|
510
|
+
if (sectionFilter && doc.section.toLowerCase() !== sectionFilter) {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const score = this.scoreDocument(doc, queryTerms);
|
|
514
|
+
if (score >= minScore) {
|
|
515
|
+
const snippet = this.generateSnippet(doc, queryTerms);
|
|
516
|
+
results.push({ doc, score, snippet });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
results.sort((a, b) => b.score - a.score);
|
|
520
|
+
return results.slice(0, limit);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Semantic search using embeddings (hybrid with keyword search)
|
|
524
|
+
*/
|
|
525
|
+
async semanticSearch(query, options) {
|
|
526
|
+
if (!this.embeddingsGenerated) {
|
|
527
|
+
await this.generateEmbeddings();
|
|
528
|
+
}
|
|
529
|
+
const limit = Math.min(
|
|
530
|
+
options?.limit || activeConfig.search.defaultLimit,
|
|
531
|
+
activeConfig.search.maxLimit
|
|
532
|
+
);
|
|
533
|
+
const sectionFilter = options?.section?.toLowerCase();
|
|
534
|
+
const queryEmbedding = await this.embeddingService.generateEmbedding(query);
|
|
535
|
+
const docs = Array.from(this.documentIndex.values()).filter((doc) => {
|
|
536
|
+
if (sectionFilter && doc.section.toLowerCase() !== sectionFilter) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
return doc.embedding !== void 0;
|
|
540
|
+
});
|
|
541
|
+
const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
542
|
+
const results = [];
|
|
543
|
+
for (const doc of docs) {
|
|
544
|
+
const keywordScore = this.scoreDocument(doc, queryTerms) / 100;
|
|
545
|
+
const semanticScore = this.embeddingService.cosineSimilarity(
|
|
546
|
+
queryEmbedding,
|
|
547
|
+
doc.embedding
|
|
548
|
+
);
|
|
549
|
+
const hybridScore = activeConfig.search.hybridKeywordWeight * keywordScore + activeConfig.search.hybridSemanticWeight * semanticScore;
|
|
550
|
+
if (semanticScore >= activeConfig.search.semanticMinSimilarity) {
|
|
551
|
+
const snippet = this.generateSnippet(doc, queryTerms);
|
|
552
|
+
results.push({ doc, score: hybridScore, snippet });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
results.sort((a, b) => b.score - a.score);
|
|
556
|
+
return results.slice(0, limit);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Score a document based on query terms
|
|
560
|
+
*/
|
|
561
|
+
scoreDocument(doc, queryTerms) {
|
|
562
|
+
let score = 0;
|
|
563
|
+
const titleLower = doc.metadata.title.toLowerCase();
|
|
564
|
+
const plainTextLower = doc.plainText.toLowerCase();
|
|
565
|
+
const pathLower = doc.path.toLowerCase();
|
|
566
|
+
for (const term of queryTerms) {
|
|
567
|
+
if (titleLower.includes(term)) {
|
|
568
|
+
score += 10;
|
|
569
|
+
}
|
|
570
|
+
if (pathLower.includes(term)) {
|
|
571
|
+
score += 5;
|
|
572
|
+
}
|
|
573
|
+
const regex = new RegExp(term, "gi");
|
|
574
|
+
const matches = plainTextLower.match(regex);
|
|
575
|
+
if (matches) {
|
|
576
|
+
score += matches.length * 0.5;
|
|
577
|
+
}
|
|
578
|
+
if (doc.metadata.description?.toLowerCase().includes(term)) {
|
|
579
|
+
score += 3;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return score;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Generate context snippet showing matched text
|
|
586
|
+
*/
|
|
587
|
+
generateSnippet(doc, queryTerms) {
|
|
588
|
+
const plainText = doc.plainText;
|
|
589
|
+
let firstMatchIndex = -1;
|
|
590
|
+
let matchedTerm = "";
|
|
591
|
+
for (const term of queryTerms) {
|
|
592
|
+
const index = plainText.toLowerCase().indexOf(term);
|
|
593
|
+
if (index !== -1 && (firstMatchIndex === -1 || index < firstMatchIndex)) {
|
|
594
|
+
firstMatchIndex = index;
|
|
595
|
+
matchedTerm = term;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (firstMatchIndex === -1) {
|
|
599
|
+
return doc.metadata.description || plainText.slice(0, 150) + "...";
|
|
600
|
+
}
|
|
601
|
+
const contextRadius = 75;
|
|
602
|
+
const start = Math.max(0, firstMatchIndex - contextRadius);
|
|
603
|
+
const end = Math.min(plainText.length, firstMatchIndex + matchedTerm.length + contextRadius);
|
|
604
|
+
let snippet = plainText.slice(start, end);
|
|
605
|
+
if (start > 0) snippet = "..." + snippet;
|
|
606
|
+
if (end < plainText.length) snippet = snippet + "...";
|
|
607
|
+
return snippet.trim();
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Get document by exact path
|
|
611
|
+
* @param path - Document path relative to content root
|
|
612
|
+
* @returns Parsed document or null if not found
|
|
613
|
+
*/
|
|
614
|
+
async getDocByPath(path2) {
|
|
615
|
+
if (!this.indexed) {
|
|
616
|
+
await this.indexDocuments();
|
|
617
|
+
}
|
|
618
|
+
const normalizedPath = path2.replace(/\.mdx?$/, "");
|
|
619
|
+
return this.documentIndex.get(normalizedPath) || null;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* List all available sections
|
|
623
|
+
*/
|
|
624
|
+
getSections() {
|
|
625
|
+
return [...activeConfig.sections];
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get all documents in a section
|
|
629
|
+
*/
|
|
630
|
+
async getDocsBySection(section) {
|
|
631
|
+
if (!this.indexed) {
|
|
632
|
+
await this.indexDocuments();
|
|
633
|
+
}
|
|
634
|
+
const sectionLower = section.toLowerCase();
|
|
635
|
+
const docs = [];
|
|
636
|
+
for (const doc of this.documentIndex.values()) {
|
|
637
|
+
if (doc.section.toLowerCase() === sectionLower) {
|
|
638
|
+
docs.push(doc);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return docs;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// ../../src/summarizer.ts
|
|
646
|
+
function summarizeContent(content, maxLength = 1500) {
|
|
647
|
+
let summary = content.replace(/```[\s\S]*?```/g, "");
|
|
648
|
+
summary = summary.replace(/^---[\s\S]*?---\n/, "");
|
|
649
|
+
const paragraphs = summary.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#"));
|
|
650
|
+
let result = "";
|
|
651
|
+
let headings = [];
|
|
652
|
+
const headingMatches = content.match(/^#{1,3}\s+(.+)$/gm);
|
|
653
|
+
if (headingMatches) {
|
|
654
|
+
headings = headingMatches.slice(0, 5).map((h) => h.replace(/^#+\s*/, "- "));
|
|
655
|
+
}
|
|
656
|
+
for (const para of paragraphs.slice(0, 3)) {
|
|
657
|
+
result += para + "\n\n";
|
|
658
|
+
}
|
|
659
|
+
if (headings.length > 0 && result.length < maxLength * 0.7) {
|
|
660
|
+
result += "\n**Content structure:**\n" + headings.join("\n");
|
|
661
|
+
}
|
|
662
|
+
if (result.length > maxLength) {
|
|
663
|
+
result = result.slice(0, maxLength) + "...";
|
|
664
|
+
}
|
|
665
|
+
return result.trim();
|
|
666
|
+
}
|
|
667
|
+
function extractStructure(content) {
|
|
668
|
+
const lines = content.split("\n");
|
|
669
|
+
const structure = [];
|
|
670
|
+
let currentHeading = "";
|
|
671
|
+
let capturedFirstLine = false;
|
|
672
|
+
for (const line of lines) {
|
|
673
|
+
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
|
|
674
|
+
if (headingMatch) {
|
|
675
|
+
currentHeading = headingMatch[2];
|
|
676
|
+
structure.push(`
|
|
677
|
+
**${currentHeading}**`);
|
|
678
|
+
capturedFirstLine = false;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (currentHeading && !capturedFirstLine && line.trim().length > 20) {
|
|
682
|
+
const cleaned = line.replace(/[*_`]/g, "").trim();
|
|
683
|
+
if (!cleaned.startsWith("<") && !cleaned.startsWith("[")) {
|
|
684
|
+
structure.push(cleaned.slice(0, 100));
|
|
685
|
+
capturedFirstLine = true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return structure.join("\n");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ../../src/server.ts
|
|
693
|
+
function titleCase(section) {
|
|
694
|
+
return section.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
695
|
+
}
|
|
696
|
+
function buildDocUrl(doc) {
|
|
697
|
+
let slug = doc.path;
|
|
698
|
+
const id = doc.metadata.id;
|
|
699
|
+
if (activeConfig.docUrl.useFrontmatterId && id) {
|
|
700
|
+
if (id.includes("/")) {
|
|
701
|
+
slug = id;
|
|
702
|
+
} else {
|
|
703
|
+
const parts = doc.path.split("/");
|
|
704
|
+
parts[parts.length - 1] = id;
|
|
705
|
+
slug = parts.join("/");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return `${activeConfig.docUrl.base}/${slug}`;
|
|
709
|
+
}
|
|
710
|
+
async function createServer() {
|
|
711
|
+
const docsManager = new DocsManager();
|
|
712
|
+
const searchEngine = new SearchEngine(docsManager);
|
|
713
|
+
const server = new Server(
|
|
714
|
+
{
|
|
715
|
+
name: activeConfig.server.name,
|
|
716
|
+
version: activeConfig.server.version
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
capabilities: {
|
|
720
|
+
resources: {},
|
|
721
|
+
tools: {}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
);
|
|
725
|
+
const searchDocsSchema = z.object({
|
|
726
|
+
query: z.string().describe("Search query string"),
|
|
727
|
+
section: z.string().optional().describe(`Filter by section (${activeConfig.sections.join(", ")})`),
|
|
728
|
+
limit: z.number().min(1).max(activeConfig.search.maxLimit).optional().describe("Maximum number of results")
|
|
729
|
+
});
|
|
730
|
+
const getDocSchema = z.object({
|
|
731
|
+
path: z.string().describe('Document path (e.g., "learn/hooks/useState")')
|
|
732
|
+
});
|
|
733
|
+
const resourceUriRegex = new RegExp(`^${activeConfig.resourceUriScheme}:\\/\\/(.+)$`);
|
|
734
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
735
|
+
return {
|
|
736
|
+
resources: activeConfig.sections.map((section) => {
|
|
737
|
+
const override = activeConfig.sectionResourceOverrides?.[section];
|
|
738
|
+
return {
|
|
739
|
+
uri: `${activeConfig.resourceUriScheme}://${section}`,
|
|
740
|
+
name: override?.name ?? `${activeConfig.docsLabel} ${titleCase(section)} Documentation`,
|
|
741
|
+
description: override?.description ?? `${activeConfig.docsLabel} documentation for the ${section} section`,
|
|
742
|
+
mimeType: "text/plain"
|
|
743
|
+
};
|
|
744
|
+
})
|
|
745
|
+
};
|
|
746
|
+
});
|
|
747
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
748
|
+
const uri = request.params.uri.toString();
|
|
749
|
+
const match = uri.match(resourceUriRegex);
|
|
750
|
+
if (!match) {
|
|
751
|
+
throw new Error(`Invalid resource URI: ${uri}`);
|
|
752
|
+
}
|
|
753
|
+
const resourcePath = match[1];
|
|
754
|
+
if (activeConfig.sections.includes(resourcePath)) {
|
|
755
|
+
const docs = await searchEngine.getDocsBySection(resourcePath);
|
|
756
|
+
const docList = docs.map((doc2) => `- ${doc2.metadata.title} (${doc2.path})`).join("\n");
|
|
757
|
+
return {
|
|
758
|
+
contents: [
|
|
759
|
+
{
|
|
760
|
+
uri,
|
|
761
|
+
mimeType: "text/plain",
|
|
762
|
+
text: `# ${resourcePath} Documentation
|
|
763
|
+
|
|
764
|
+
Available documents:
|
|
765
|
+
|
|
766
|
+
${docList}`
|
|
767
|
+
}
|
|
768
|
+
]
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const doc = await searchEngine.getDocByPath(resourcePath);
|
|
772
|
+
if (!doc) {
|
|
773
|
+
throw new Error(`Document not found: ${resourcePath}`);
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
contents: [
|
|
777
|
+
{
|
|
778
|
+
uri,
|
|
779
|
+
mimeType: "text/markdown",
|
|
780
|
+
text: `# ${doc.metadata.title}
|
|
781
|
+
|
|
782
|
+
${doc.content}`
|
|
783
|
+
}
|
|
784
|
+
]
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
788
|
+
return {
|
|
789
|
+
tools: [
|
|
790
|
+
{
|
|
791
|
+
name: activeConfig.searchToolName,
|
|
792
|
+
description: activeConfig.searchToolDescription,
|
|
793
|
+
inputSchema: {
|
|
794
|
+
type: "object",
|
|
795
|
+
properties: {
|
|
796
|
+
query: {
|
|
797
|
+
type: "string",
|
|
798
|
+
description: "Search query string"
|
|
799
|
+
},
|
|
800
|
+
section: {
|
|
801
|
+
type: "string",
|
|
802
|
+
description: `Filter by section (${activeConfig.sections.join(", ")})`,
|
|
803
|
+
enum: [...activeConfig.sections]
|
|
804
|
+
},
|
|
805
|
+
limit: {
|
|
806
|
+
type: "number",
|
|
807
|
+
description: "Maximum number of results",
|
|
808
|
+
minimum: 1,
|
|
809
|
+
maximum: activeConfig.search.maxLimit
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
required: ["query"]
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: "list_sections",
|
|
817
|
+
description: "List all available documentation sections",
|
|
818
|
+
inputSchema: {
|
|
819
|
+
type: "object",
|
|
820
|
+
properties: {}
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: "get_doc",
|
|
825
|
+
description: `Get a concise summary of a documentation page (~1500 chars). Use ${activeConfig.searchToolName} first - only call this if you need more detail than the search snippet provides.`,
|
|
826
|
+
inputSchema: {
|
|
827
|
+
type: "object",
|
|
828
|
+
properties: {
|
|
829
|
+
path: {
|
|
830
|
+
type: "string",
|
|
831
|
+
description: 'Document path (e.g., "learn/hooks/useState")'
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
required: ["path"]
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: "update_docs",
|
|
839
|
+
description: "Pull latest documentation from Git repository",
|
|
840
|
+
inputSchema: {
|
|
841
|
+
type: "object",
|
|
842
|
+
properties: {}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
]
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
849
|
+
const { name, arguments: args } = request.params;
|
|
850
|
+
try {
|
|
851
|
+
switch (name) {
|
|
852
|
+
case activeConfig.searchToolName: {
|
|
853
|
+
const { query, section, limit } = searchDocsSchema.parse(args);
|
|
854
|
+
const results = await searchEngine.search(query, { section, limit });
|
|
855
|
+
return {
|
|
856
|
+
content: [
|
|
857
|
+
{
|
|
858
|
+
type: "text",
|
|
859
|
+
text: JSON.stringify(
|
|
860
|
+
results.map((r) => ({
|
|
861
|
+
path: r.doc.path,
|
|
862
|
+
title: r.doc.metadata.title,
|
|
863
|
+
snippet: r.snippet,
|
|
864
|
+
score: r.score,
|
|
865
|
+
url: buildDocUrl(r.doc)
|
|
866
|
+
})),
|
|
867
|
+
null,
|
|
868
|
+
2
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
]
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
case "list_sections": {
|
|
875
|
+
const sections = searchEngine.getSections();
|
|
876
|
+
return {
|
|
877
|
+
content: [
|
|
878
|
+
{
|
|
879
|
+
type: "text",
|
|
880
|
+
text: JSON.stringify(sections, null, 2)
|
|
881
|
+
}
|
|
882
|
+
]
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
case "get_doc": {
|
|
886
|
+
const { path: path2 } = getDocSchema.parse(args);
|
|
887
|
+
const doc = await searchEngine.getDocByPath(path2);
|
|
888
|
+
if (!doc) {
|
|
889
|
+
throw new Error(`Document not found: ${path2}`);
|
|
890
|
+
}
|
|
891
|
+
const summary = summarizeContent(doc.content, 1500);
|
|
892
|
+
const structure = extractStructure(doc.content);
|
|
893
|
+
return {
|
|
894
|
+
content: [
|
|
895
|
+
{
|
|
896
|
+
type: "text",
|
|
897
|
+
text: JSON.stringify(
|
|
898
|
+
{
|
|
899
|
+
path: doc.path,
|
|
900
|
+
section: doc.section,
|
|
901
|
+
title: doc.metadata.title,
|
|
902
|
+
description: doc.metadata.description,
|
|
903
|
+
summary,
|
|
904
|
+
structure,
|
|
905
|
+
url: buildDocUrl(doc),
|
|
906
|
+
note: "This is a summary. Visit the URL for full documentation."
|
|
907
|
+
},
|
|
908
|
+
null,
|
|
909
|
+
2
|
|
910
|
+
)
|
|
911
|
+
}
|
|
912
|
+
]
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
case "update_docs": {
|
|
916
|
+
const updated = await docsManager.updateRepo();
|
|
917
|
+
if (updated) {
|
|
918
|
+
await searchEngine.indexDocuments();
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
content: [
|
|
922
|
+
{
|
|
923
|
+
type: "text",
|
|
924
|
+
text: JSON.stringify(
|
|
925
|
+
{
|
|
926
|
+
updated,
|
|
927
|
+
message: updated ? "Documentation updated successfully" : "Documentation already up to date"
|
|
928
|
+
},
|
|
929
|
+
null,
|
|
930
|
+
2
|
|
931
|
+
)
|
|
932
|
+
}
|
|
933
|
+
]
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
default:
|
|
937
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
938
|
+
}
|
|
939
|
+
} catch (error) {
|
|
940
|
+
if (error instanceof z.ZodError) {
|
|
941
|
+
throw new Error(`Invalid arguments: ${error.message}`);
|
|
942
|
+
}
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
console.error(`Initializing ${activeConfig.docsLabel} Docs MCP Server...`);
|
|
947
|
+
await docsManager.initialize();
|
|
948
|
+
const transport = new StdioServerTransport();
|
|
949
|
+
await server.connect(transport);
|
|
950
|
+
console.error(`${activeConfig.docsLabel} Docs MCP Server running`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// src/index.ts
|
|
954
|
+
configure(reactNativeDocsPreset);
|
|
955
|
+
createServer().catch((error) => {
|
|
956
|
+
console.error("Failed to start server:", error);
|
|
957
|
+
process.exit(1);
|
|
958
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-docs-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server providing AI agents with semantic search over React Native documentation",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"react-native-docs-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format esm --platform node --target node18 --out-dir dist --clean",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"react-native",
|
|
20
|
+
"react-native-docs",
|
|
21
|
+
"documentation",
|
|
22
|
+
"ai",
|
|
23
|
+
"claude",
|
|
24
|
+
"cursor",
|
|
25
|
+
"semantic-search",
|
|
26
|
+
"embeddings",
|
|
27
|
+
"vector-search"
|
|
28
|
+
],
|
|
29
|
+
"author": "sannnao",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/Sannnao/react-docs-mcp.git",
|
|
34
|
+
"directory": "packages/react-native-docs-mcp"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Sannnao/react-docs-mcp/tree/master/packages/react-native-docs-mcp#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/Sannnao/react-docs-mcp/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist/**/*.js",
|
|
45
|
+
"dist/**/*.js.map",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
50
|
+
"@xenova/transformers": "^2.17.2",
|
|
51
|
+
"fast-glob": "^3.3.3",
|
|
52
|
+
"gray-matter": "^4.0.3",
|
|
53
|
+
"remark": "^15.0.1",
|
|
54
|
+
"simple-git": "^3.28.0",
|
|
55
|
+
"strip-markdown": "^6.0.0",
|
|
56
|
+
"zod": "^3.25.76"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"tsup": "^8.5.0",
|
|
60
|
+
"tsx": "^4.20.6",
|
|
61
|
+
"typescript": "^5.9.3"
|
|
62
|
+
}
|
|
63
|
+
}
|