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.
Files changed (3) hide show
  1. package/README.md +133 -0
  2. package/dist/index.js +958 -0
  3. 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
+ }