react-native-docs-mcp 0.1.0 → 0.2.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 +31 -0
  2. package/dist/index.js +207 -96
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -52,12 +52,15 @@ Edit: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
52
52
 
53
53
  ## Features
54
54
 
55
+ - **🔑 No API Key**: Unlike hosted docs services (Context7, GitMCP), everything runs on your machine — no account, no key, no rate limits
56
+ - **🔌 Works Offline**: Clones the official react-native-website docs repo once, then searches locally — no network calls at query time
55
57
  - **🔍 Semantic Search**: AI-powered search using embeddings for conceptual matches
56
58
  - **⚡ Fast Results**: In-memory vector search with hybrid keyword+semantic ranking
57
59
  - **📦 Zero Config**: Works with `npx` - no installation needed
58
60
  - **🤖 Local AI**: Runs embeddings locally (no API costs)
59
61
  - **📝 Concise Responses**: Returns summaries instead of full documentation
60
62
  - **🔄 Auto-sync**: Pulls latest docs from the react-native-website repo automatically
63
+ - **📌 Version Pinning**: `--docs-version=0.77` scopes docs to the React Native release your app actually uses
61
64
 
62
65
  ## Usage
63
66
 
@@ -88,6 +91,7 @@ Get a specific documentation page.
88
91
  **Parameters**:
89
92
 
90
93
  - `path` (required): Document path (e.g., "getting-started", "the-new-architecture/using-codegen")
94
+ - `full` (optional): Return the full raw page instead of the ~1500 char summary (default: false)
91
95
 
92
96
  **Example**:
93
97
 
@@ -95,6 +99,13 @@ Get a specific documentation page.
95
99
  Get the React Native flexbox documentation
96
100
  ```
97
101
 
102
+ **Why `full`?** The default summary is enough for most reference pages, but long guides — like "The New Architecture" migration docs, or a full native-modules walkthrough — can run well past 1500 chars, and the summary may stop before the part you actually need. Ask for the complete page when that happens:
103
+
104
+ ```
105
+ Get the full page for the-new-architecture/using-codegen, I need every step
106
+ ```
107
+ which calls `get_doc` with `{ "path": "the-new-architecture/using-codegen", "full": true }`.
108
+
98
109
  #### `list_sections`
99
110
 
100
111
  List all available documentation sections.
@@ -103,6 +114,26 @@ List all available documentation sections.
103
114
 
104
115
  Pull latest documentation from the Git repository.
105
116
 
117
+ ### Docs version
118
+
119
+ By default this server indexes the always-current `docs/` folder from the react-native-website repo — the same docs shown on reactnative.dev today. To pin it to a specific past release's frozen docs snapshot instead, pass `--docs-version`:
120
+
121
+ ```bash
122
+ npx react-native-docs-mcp --docs-version=0.77
123
+ ```
124
+
125
+ or set the `REACT_NATIVE_DOCS_VERSION` env var (the CLI flag wins if both are set). With Claude Code:
126
+
127
+ ```bash
128
+ claude mcp add --transport stdio react-native-docs -- npx react-native-docs-mcp --docs-version=0.77
129
+ ```
130
+
131
+ (Bare `--version` prints the package version, as you'd expect from any CLI.)
132
+
133
+ **Why pin a version?** If your app is running React Native 0.77 but the agent searches always-current docs, it can suggest an API that only exists in 0.86, or miss that something was renamed/removed since your version. Pinning `--docs-version` to match your `react-native` dependency's version keeps suggestions consistent with the APIs actually available in your app — useful when working on an app that's a few releases behind latest, or when debugging something version-specific (e.g. "did this New Architecture behavior change between 0.78 and 0.82?" — run two instances, one per version, and compare).
134
+
135
+ Only `latest` (the default) is fully verified against the current docs structure; older version snapshots are indexed best-effort with the same settings.
136
+
106
137
  ### Resources
107
138
 
108
139
  The server exposes documentation as resources with the URI pattern:
package/dist/index.js CHANGED
@@ -3,6 +3,59 @@
3
3
  // ../../src/config.ts
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
6
+
7
+ // ../../src/presets/searchDefaults.ts
8
+ var DEFAULT_SEARCH = {
9
+ defaultLimit: 10,
10
+ maxLimit: 50,
11
+ minScore: 0.1,
12
+ semanticSearchEnabled: true,
13
+ semanticMinSimilarity: 0.3,
14
+ hybridKeywordWeight: 0.3,
15
+ hybridSemanticWeight: 0.7
16
+ };
17
+
18
+ // ../../src/presets/reactDocs.ts
19
+ var reactDocsPreset = {
20
+ cacheDirName: "react-docs-mcp",
21
+ repoFolderName: "react-dev-repo",
22
+ repo: {
23
+ url: "https://github.com/reactjs/react.dev.git",
24
+ contentPath: "src/content"
25
+ },
26
+ search: { ...DEFAULT_SEARCH },
27
+ server: {
28
+ name: "react-docs-mcp",
29
+ version: "1.2.0"
30
+ },
31
+ sections: ["learn", "reference", "blog", "community"],
32
+ resourceUriScheme: "react-docs",
33
+ docsLabel: "React",
34
+ searchToolName: "search_react_docs",
35
+ searchToolDescription: "Search across React documentation. Returns relevant documentation pages with snippets.",
36
+ pathExample: "reference/react/useState",
37
+ docUrl: { base: "https://react.dev", useFrontmatterId: false },
38
+ sectionResourceOverrides: {
39
+ learn: {
40
+ name: "React Learn Documentation",
41
+ description: "Interactive React tutorial and learning materials"
42
+ },
43
+ reference: {
44
+ name: "React API Reference",
45
+ description: "Complete React API reference documentation"
46
+ },
47
+ blog: {
48
+ name: "React Blog",
49
+ description: "React team blog posts and announcements"
50
+ },
51
+ community: {
52
+ name: "React Community Documentation",
53
+ description: "Community resources, code of conduct, and translations"
54
+ }
55
+ }
56
+ };
57
+
58
+ // ../../src/config.ts
6
59
  var getCacheDir = (cacheDirName) => {
7
60
  const platform = process.platform;
8
61
  const home = homedir();
@@ -24,66 +77,46 @@ function resolve(preset) {
24
77
  }
25
78
  };
26
79
  }
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);
80
+ var activeConfig = resolve(reactDocsPreset);
55
81
  function configure(preset) {
56
82
  activeConfig = resolve(preset);
57
83
  }
58
84
 
59
85
  // ../../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
- };
86
+ var LATEST_VERSION = "latest";
87
+ function resolveReactNativeDocsPreset(version = LATEST_VERSION) {
88
+ const isLatest = version === LATEST_VERSION;
89
+ if (!isLatest && !/^\d+\.\d+$/.test(version)) {
90
+ throw new Error(
91
+ `Invalid React Native docs version "${version}". Expected a release like "0.77" or "${LATEST_VERSION}".`
92
+ );
93
+ }
94
+ return {
95
+ cacheDirName: "react-native-docs-mcp",
96
+ repoFolderName: "react-native-website-repo",
97
+ repo: {
98
+ url: "https://github.com/facebook/react-native-website.git",
99
+ contentPath: isLatest ? "docs" : `website/versioned_docs/version-${version}`
100
+ },
101
+ search: { ...DEFAULT_SEARCH },
102
+ server: {
103
+ name: "react-native-docs-mcp",
104
+ version: "0.2.0"
105
+ },
106
+ sections: ["the-new-architecture", "legacy", "releases"],
107
+ resourceUriScheme: "react-native-docs",
108
+ docsLabel: "React Native",
109
+ searchToolName: "search_react_native_docs",
110
+ searchToolDescription: "Search across React Native documentation. Returns relevant documentation pages with snippets.",
111
+ pathExample: "the-new-architecture/using-codegen",
112
+ docUrl: {
113
+ base: isLatest ? "https://reactnative.dev/docs" : `https://reactnative.dev/docs/${version}`,
114
+ // Frontmatter-id routing is a property of the Docusaurus content format,
115
+ // not of the version — versioned snapshots carry the same id overrides
116
+ useFrontmatterId: true
117
+ }
118
+ };
119
+ }
87
120
 
88
121
  // ../../src/server.ts
89
122
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -118,12 +151,19 @@ var DocsManager = class {
118
151
  async initialize() {
119
152
  const repoExists = await this.checkRepoExists();
120
153
  if (!repoExists) {
121
- console.log("Cloning React documentation repository...");
154
+ console.log("Cloning documentation repository...");
122
155
  await this.cloneRepo();
123
156
  console.log("Repository cloned successfully");
124
157
  } else {
125
158
  console.log("Repository already exists");
126
159
  }
160
+ try {
161
+ await fs.access(this.contentPath);
162
+ } catch {
163
+ throw new Error(
164
+ `Docs content path not found: ${this.contentPath}. The cloned repository has no such folder \u2014 if you passed --docs-version, check that this docs version exists.`
165
+ );
166
+ }
127
167
  }
128
168
  /**
129
169
  * Check if repository exists locally
@@ -137,16 +177,33 @@ var DocsManager = class {
137
177
  }
138
178
  }
139
179
  /**
140
- * Clone the repository
180
+ * Clone the repository.
181
+ * Clones into a temp directory and renames into place so that two
182
+ * processes starting on a fresh machine (e.g. two MCP servers sharing one
183
+ * cache) don't corrupt each other; the loser discards its redundant copy.
141
184
  */
142
185
  async cloneRepo() {
186
+ const tempPath = `${this.repoPath}.cloning-${process.pid}`;
143
187
  try {
144
188
  await fs.mkdir(path.dirname(this.repoPath), { recursive: true });
145
- await this.git.clone(activeConfig.repo.url, this.repoPath, {
189
+ await this.git.clone(activeConfig.repo.url, tempPath, {
146
190
  "--depth": 1
147
191
  // Shallow clone for faster download
148
192
  });
193
+ try {
194
+ await fs.rename(tempPath, this.repoPath);
195
+ } catch (renameError) {
196
+ if (await this.checkRepoExists()) {
197
+ await fs.rm(tempPath, { recursive: true, force: true });
198
+ return;
199
+ }
200
+ throw renameError;
201
+ }
149
202
  } catch (error) {
203
+ try {
204
+ await fs.rm(tempPath, { recursive: true, force: true });
205
+ } catch {
206
+ }
150
207
  throw new Error(
151
208
  `Failed to clone repository: ${error instanceof Error ? error.message : String(error)}`
152
209
  );
@@ -243,6 +300,25 @@ var DocsManager = class {
243
300
  this.fileCache.set(cacheKey, files);
244
301
  return files;
245
302
  }
303
+ /**
304
+ * Filter a list of section names down to those whose directory actually
305
+ * exists under the content root. Lets the server avoid advertising
306
+ * sections that are empty in the checked-out docs (e.g. a versioned
307
+ * snapshot that predates a section).
308
+ */
309
+ async getExistingSections(sections) {
310
+ const checks = await Promise.all(
311
+ sections.map(async (section) => {
312
+ try {
313
+ await fs.access(path.join(this.contentPath, section));
314
+ return section;
315
+ } catch {
316
+ return null;
317
+ }
318
+ })
319
+ );
320
+ return checks.filter((section) => section !== null);
321
+ }
246
322
  /**
247
323
  * Read file content
248
324
  * @param relativePath - Path relative to content root
@@ -309,11 +385,14 @@ function extractSection(path2) {
309
385
  function normalizePath(filePath) {
310
386
  return filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\.mdx?$/, "");
311
387
  }
388
+ function titleCase(slug) {
389
+ return slug.replace(/[-_]/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
390
+ }
312
391
  function extractTitleFromPath(filePath) {
313
392
  const normalized = normalizePath(filePath);
314
393
  const parts = normalized.split("/");
315
394
  const filename = parts[parts.length - 1] || "Untitled";
316
- return filename.replace(/[-_]/g, " ").split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
395
+ return titleCase(filename);
317
396
  }
318
397
 
319
398
  // ../../src/embeddingService.ts
@@ -415,6 +494,17 @@ var EmbeddingService = class {
415
494
  };
416
495
 
417
496
  // ../../src/searchEngine.ts
497
+ function resolveDocSlug(path2, id) {
498
+ if (typeof id !== "string" || id.length === 0) {
499
+ return path2;
500
+ }
501
+ if (id.includes("/")) {
502
+ return id;
503
+ }
504
+ const parts = path2.split("/");
505
+ parts[parts.length - 1] = id;
506
+ return parts.join("/");
507
+ }
418
508
  var SearchEngine = class {
419
509
  docsManager;
420
510
  embeddingService;
@@ -441,12 +531,16 @@ var SearchEngine = class {
441
531
  try {
442
532
  const content = await this.docsManager.readDoc(docPath);
443
533
  const parsedDoc = await parseMarkdown(content, docPath);
534
+ if (activeConfig.docUrl.useFrontmatterId) {
535
+ parsedDoc.path = resolveDocSlug(parsedDoc.path, parsedDoc.metadata.id);
536
+ }
444
537
  this.documentIndex.set(parsedDoc.path, parsedDoc);
445
538
  } catch (error) {
446
539
  console.warn(`Failed to index document ${docPath}:`, error);
447
540
  }
448
541
  }
449
542
  this.indexed = true;
543
+ this.embeddingsGenerated = false;
450
544
  console.log(`Indexed ${this.documentIndex.size} documents`);
451
545
  }
452
546
  /**
@@ -615,14 +709,7 @@ var SearchEngine = class {
615
709
  if (!this.indexed) {
616
710
  await this.indexDocuments();
617
711
  }
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];
712
+ return this.documentIndex.get(normalizePath(path2)) || null;
626
713
  }
627
714
  /**
628
715
  * Get all documents in a section
@@ -690,22 +777,11 @@ function extractStructure(content) {
690
777
  }
691
778
 
692
779
  // ../../src/server.ts
693
- function titleCase(section) {
694
- return section.split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
780
+ function escapeRegExp(literal) {
781
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
695
782
  }
696
783
  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}`;
784
+ return `${activeConfig.docUrl.base}/${doc.path}`;
709
785
  }
710
786
  async function createServer() {
711
787
  const docsManager = new DocsManager();
@@ -724,16 +800,20 @@ async function createServer() {
724
800
  );
725
801
  const searchDocsSchema = z.object({
726
802
  query: z.string().describe("Search query string"),
727
- section: z.string().optional().describe(`Filter by section (${activeConfig.sections.join(", ")})`),
803
+ section: z.string().optional().refine((section) => section === void 0 || activeConfig.sections.includes(section), {
804
+ message: `Unknown section. Valid sections: ${activeConfig.sections.join(", ")}`
805
+ }).describe(`Filter by section (${activeConfig.sections.join(", ")})`),
728
806
  limit: z.number().min(1).max(activeConfig.search.maxLimit).optional().describe("Maximum number of results")
729
807
  });
730
808
  const getDocSchema = z.object({
731
- path: z.string().describe('Document path (e.g., "learn/hooks/useState")')
809
+ path: z.string().describe(`Document path (e.g., "${activeConfig.pathExample}")`),
810
+ full: z.boolean().optional().describe("Return the full raw page content instead of a ~1500 char summary")
732
811
  });
733
- const resourceUriRegex = new RegExp(`^${activeConfig.resourceUriScheme}:\\/\\/(.+)$`);
812
+ const resourceUriRegex = new RegExp(`^${escapeRegExp(activeConfig.resourceUriScheme)}:\\/\\/(.+)$`);
734
813
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
814
+ const existingSections = await docsManager.getExistingSections(activeConfig.sections);
735
815
  return {
736
- resources: activeConfig.sections.map((section) => {
816
+ resources: existingSections.map((section) => {
737
817
  const override = activeConfig.sectionResourceOverrides?.[section];
738
818
  return {
739
819
  uri: `${activeConfig.resourceUriScheme}://${section}`,
@@ -822,13 +902,17 @@ ${doc.content}`
822
902
  },
823
903
  {
824
904
  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.`,
905
+ description: `Get a concise summary of a documentation page (~1500 chars), or the full raw page with full:true. Use ${activeConfig.searchToolName} first - only call this if you need more detail than the search snippet provides.`,
826
906
  inputSchema: {
827
907
  type: "object",
828
908
  properties: {
829
909
  path: {
830
910
  type: "string",
831
- description: 'Document path (e.g., "learn/hooks/useState")'
911
+ description: `Document path (e.g., "${activeConfig.pathExample}")`
912
+ },
913
+ full: {
914
+ type: "boolean",
915
+ description: "Return the full raw page content instead of a ~1500 char summary"
832
916
  }
833
917
  },
834
918
  required: ["path"]
@@ -872,7 +956,7 @@ ${doc.content}`
872
956
  };
873
957
  }
874
958
  case "list_sections": {
875
- const sections = searchEngine.getSections();
959
+ const sections = await docsManager.getExistingSections(activeConfig.sections);
876
960
  return {
877
961
  content: [
878
962
  {
@@ -883,13 +967,16 @@ ${doc.content}`
883
967
  };
884
968
  }
885
969
  case "get_doc": {
886
- const { path: path2 } = getDocSchema.parse(args);
970
+ const { path: path2, full } = getDocSchema.parse(args);
887
971
  const doc = await searchEngine.getDocByPath(path2);
888
972
  if (!doc) {
889
973
  throw new Error(`Document not found: ${path2}`);
890
974
  }
891
- const summary = summarizeContent(doc.content, 1500);
892
- const structure = extractStructure(doc.content);
975
+ const body = full ? { content: doc.content, note: "Full page content." } : {
976
+ summary: summarizeContent(doc.content, 1500),
977
+ structure: extractStructure(doc.content),
978
+ note: "This is a summary. Pass full:true for the complete page, or visit the URL."
979
+ };
893
980
  return {
894
981
  content: [
895
982
  {
@@ -900,10 +987,8 @@ ${doc.content}`
900
987
  section: doc.section,
901
988
  title: doc.metadata.title,
902
989
  description: doc.metadata.description,
903
- summary,
904
- structure,
905
- url: buildDocUrl(doc),
906
- note: "This is a summary. Visit the URL for full documentation."
990
+ ...body,
991
+ url: buildDocUrl(doc)
907
992
  },
908
993
  null,
909
994
  2
@@ -951,7 +1036,33 @@ ${doc.content}`
951
1036
  }
952
1037
 
953
1038
  // src/index.ts
954
- configure(reactNativeDocsPreset);
1039
+ var argv = process.argv.slice(2);
1040
+ if (argv.includes("--version") || argv.includes("-v")) {
1041
+ console.log(resolveReactNativeDocsPreset().server.version);
1042
+ process.exit(0);
1043
+ }
1044
+ function parseDocsVersionArg(args) {
1045
+ for (const arg of args) {
1046
+ const match = arg.match(/^--docs-version=(.+)$/);
1047
+ if (match) return match[1];
1048
+ }
1049
+ const flagIndex = args.indexOf("--docs-version");
1050
+ const value = flagIndex !== -1 ? args[flagIndex + 1] : void 0;
1051
+ if (value && !value.startsWith("-")) {
1052
+ return value;
1053
+ }
1054
+ return void 0;
1055
+ }
1056
+ var docsVersion = parseDocsVersionArg(argv) ?? process.env.REACT_NATIVE_DOCS_VERSION ?? LATEST_VERSION;
1057
+ if (docsVersion !== LATEST_VERSION) {
1058
+ console.error(`Using React Native docs version: ${docsVersion} (best-effort; only "latest" is fully verified)`);
1059
+ }
1060
+ try {
1061
+ configure(resolveReactNativeDocsPreset(docsVersion));
1062
+ } catch (error) {
1063
+ console.error(error instanceof Error ? error.message : String(error));
1064
+ process.exit(1);
1065
+ }
955
1066
  createServer().catch((error) => {
956
1067
  console.error("Failed to start server:", error);
957
1068
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-docs-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server providing AI agents with semantic search over React Native documentation",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",