@react-spectrum/mcp 3.0.0-nightly-df85b152a-250928 → 3.0.0-nightly-5ede892f6-250929

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 +127 -0
  2. package/package.json +2 -2
  3. package/src/index.ts +140 -122
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # @react-spectrum/mcp
2
+
3
+ The `@react-spectrum/mcp` package allows you to run [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) servers for React Spectrum (S2) and React Aria locally. It exposes a set of tools that MCP clients can discover and call to browse the docs.
4
+
5
+ ## Using with an MCP client
6
+
7
+ Add one or both servers to your MCP client configuration (the exact file and schema may depend on your client).
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "s2-docs": {
13
+ "command": "npx",
14
+ "args": ["@react-spectrum/mcp", "s2"]
15
+ },
16
+ "react-aria-docs": {
17
+ "command": "npx",
18
+ "args": ["@react-spectrum/mcp", "react-aria"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ <details>
25
+ <summary>Cursor</summary>
26
+
27
+ #### Click the button to install:
28
+
29
+ React Spectrum (S2):
30
+
31
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=s2-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgczIifQ%3D%3D)
32
+
33
+ React Aria:
34
+
35
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=react-aria-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgcmVhY3QtYXJpYSJ9)
36
+
37
+ Or follow the MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
38
+
39
+ </details>
40
+
41
+ <details>
42
+ <summary>VS Code</summary>
43
+
44
+ #### Click the button to install:
45
+
46
+ React Spectrum (S2):
47
+
48
+ [<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](vscode:mcp/install?%7B%22name%22%3A%22s2-docs%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40react-spectrum%2Fmcp%22%2C%22s2%22%5D%7D)
49
+
50
+ React Aria:
51
+
52
+ [<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](vscode:mcp/install?%7B%22name%22%3A%22react-aria-docs%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40react-spectrum%2Fmcp%22%2C%22react-aria%22%5D%7D)
53
+
54
+
55
+ #### Or install manually:
56
+
57
+ Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) and use the standard config above. You can also add servers using the VS Code CLI:
58
+
59
+ ```bash
60
+ # For VS Code
61
+ code --add-mcp '{"name":"s2-docs","command":"npx","args":["@react-spectrum/mcp","s2"]}'
62
+ code --add-mcp '{"name":"react-aria-docs","command":"npx","args":["@react-spectrum/mcp","react-aria"]}'
63
+ ```
64
+
65
+ </details>
66
+
67
+ <details>
68
+ <summary>Claude Code</summary>
69
+
70
+ Use the Claude Code CLI to add the servers:
71
+
72
+ ```bash
73
+ claude mcp add s2-docs npx @react-spectrum/mcp s2
74
+ claude mcp add react-aria-docs npx @react-spectrum/mcp react-aria
75
+ ```
76
+ For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
77
+ </details>
78
+
79
+ <details>
80
+ <summary>Codex</summary>
81
+
82
+ Create or edit the configuration file `~/.codex/config.toml` and add:
83
+
84
+ ```toml
85
+ [mcp_servers.s2-docs]
86
+ command = "npx"
87
+ args = ["@react-spectrum/mcp", "s2"]
88
+
89
+ [mcp_servers.react-aria-docs]
90
+ command = "npx"
91
+ args = ["@react-spectrum/mcp", "react-aria"]
92
+ ```
93
+
94
+ For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
95
+
96
+ </details>
97
+
98
+ <details>
99
+ <summary>Gemini CLI</summary>
100
+
101
+ Use the Gemini CLI to add the servers:
102
+
103
+ ```bash
104
+ gemini mcp add s2-docs npx @react-spectrum/mcp s2
105
+ gemini mcp add react-aria-docs npx @react-spectrum/mcp react-aria
106
+ ```
107
+
108
+ For more information, see the [Gemini CLI MCP documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server).
109
+
110
+ </details>
111
+
112
+ <details>
113
+ <summary>Windsurf</summary>
114
+
115
+ Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above.
116
+
117
+ </details>
118
+
119
+ ## Tools
120
+
121
+ | Tool | Input | Description |
122
+ | --- | --- | --- |
123
+ | `list_pages` | `{ includeDescription?: boolean }` | List available pages in the selected docs library. |
124
+ | `get_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
125
+ | `get_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
126
+ | `search_icons` (S2 only) | `{ terms: string or string[] }` | Search S2 workflow icon names. |
127
+ | `search_illustrations` (S2 only) | `{ terms: string or string[] }` | Search S2 illustration names. |
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@react-spectrum/mcp",
3
- "version": "3.0.0-nightly-df85b152a-250928",
3
+ "version": "3.0.0-nightly-5ede892f6-250929",
4
4
  "description": "MCP server for React Spectrum (S2) and React Aria documentation",
5
5
  "type": "module",
6
6
  "bin": "dist/index.js",
7
7
  "scripts": {
8
- "build": "tsc -p tsconfig.json",
8
+ "build": "node ./scripts/build-data.mjs && tsc -p tsconfig.json",
9
9
  "start": "node dist/index.js",
10
10
  "dev": "node --enable-source-maps dist/index.js"
11
11
  },
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import fg from 'fast-glob';
3
- import {fileURLToPath, pathToFileURL} from 'url';
2
+ /// <reference types="node" />
3
+ import {fileURLToPath} from 'url';
4
4
  import fs from 'fs';
5
5
  import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import path from 'path';
@@ -37,110 +37,131 @@ function errorToString(err: unknown): string {
37
37
  }
38
38
  }
39
39
 
40
- // Resolve docs dist root based on this file location
41
40
  const __filename = fileURLToPath(import.meta.url);
42
41
  const __dirname = path.dirname(__filename);
43
- const DOCS_DIST_ROOT = path.resolve(__dirname, '../../s2-docs/dist');
44
42
 
45
- function assertDocsExist() {
46
- if (!fs.existsSync(DOCS_DIST_ROOT)) {
47
- const hint = path.resolve(__dirname, '../../s2-docs/scripts/generateMarkdownDocs.mjs');
48
- throw new Error(`S2 docs dist not found at ${DOCS_DIST_ROOT}. Build them first via: yarn workspace @react-spectrum/s2-docs generate:md (script: ${hint})`);
43
+ // CDN base for docs. Can be overridden via env variable.
44
+ const DEFAULT_CDN_BASE = process.env.DOCS_CDN_BASE
45
+ ?? 'https://reactspectrum.blob.core.windows.net/reactspectrum/7d2883a56fb1a0554864b21324d405f758deb3ce/s2-docs';
46
+
47
+ function libBaseUrl(library: Library) {
48
+ return `${DEFAULT_CDN_BASE}/${library}`;
49
+ }
50
+
51
+ async function fetchText(url: string, timeoutMs = 15000): Promise<string> {
52
+ const ctrl = new AbortController();
53
+ const id = setTimeout(() => ctrl.abort(), timeoutMs).unref?.();
54
+ try {
55
+ const res = await fetch(url, {signal: ctrl.signal, cache: 'no-store'} as any);
56
+ if (!res.ok) {
57
+ throw new Error(`HTTP ${res.status} for ${url}`);
58
+ }
59
+ return await res.text();
60
+ } finally {
61
+ clearTimeout(id as any);
49
62
  }
50
63
  }
51
64
 
52
65
  // Cache of parsed pages
53
66
  const pageCache = new Map<string, PageInfo>();
54
67
 
55
- const ICONS_DIR = path.resolve(__dirname, '../../../@react-spectrum/s2/s2wf-icons');
56
68
  let iconIdCache: string[] | null = null;
57
- const ILLUSTRATIONS_DIR = path.resolve(__dirname, '../../../@react-spectrum/s2/spectrum-illustrations/linear');
58
69
  let illustrationIdCache: string[] | null = null;
59
70
  let iconAliasesCache: Record<string, string[]> | null = null;
60
71
  let illustrationAliasesCache: Record<string, string[]> | null = null;
61
72
 
62
- function ensureIconsExist() {
63
- if (!fs.existsSync(ICONS_DIR)) {
64
- throw new Error(`S2 icons directory not found at ${ICONS_DIR}`);
73
+ function readBundledJson(filename: string): any | null {
74
+ try {
75
+ const p = path.resolve(__dirname, 'data', filename); // dist/data
76
+ if (!fs.existsSync(p)) {return null;}
77
+ const txt = fs.readFileSync(p, 'utf8');
78
+ return JSON.parse(txt);
79
+ } catch {
80
+ return null;
65
81
  }
66
82
  }
67
83
 
68
84
  function listIconNames(): string[] {
69
85
  if (iconIdCache) {return iconIdCache;}
70
- ensureIconsExist();
71
- const files = fg.sync('*.svg', {cwd: ICONS_DIR, absolute: false, suppressErrors: true});
72
- const ids = Array.from(new Set(
73
- files.map(f => f.replace(/\.svg$/i, '')
74
- // Mirror IconPicker.tsx regex to derive the id from the filename
75
- .replace(/^S2_Icon_(.*?)(Size\d+)?_2.*/, '$1'))
76
- )).sort((a, b) => a.localeCompare(b));
77
- iconIdCache = ids;
78
- return ids;
79
- }
80
-
81
- function ensureIllustrationsExist() {
82
- if (!fs.existsSync(ILLUSTRATIONS_DIR)) {
83
- throw new Error(`S2 illustrations directory not found at ${ILLUSTRATIONS_DIR}`);
84
- }
86
+ const bundled = readBundledJson('icons.json');
87
+ return (iconIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
85
88
  }
86
89
 
87
90
  function listIllustrationNames(): string[] {
88
91
  if (illustrationIdCache) {return illustrationIdCache;}
89
- ensureIllustrationsExist();
90
- // linear directory may contain multiple sizes per illustration name
91
- const files = fg.sync('**/*.svg', {cwd: ILLUSTRATIONS_DIR, absolute: false, suppressErrors: true});
92
- const ids = Array.from(new Set(
93
- files.map(f => {
94
- const base = f.replace(/\.svg$/i, '')
95
- // Pattern: S2_lin_<name>_<size>
96
- .replace(/^S2_lin_(.*)_\d+$/, '$1');
97
- return base ? (base.charAt(0).toUpperCase() + base.slice(1)) : base;
98
- })
99
- )).sort((a, b) => a.localeCompare(b));
100
- illustrationIdCache = ids;
101
- return ids;
92
+ const bundled = readBundledJson('illustrations.json');
93
+ return (illustrationIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
102
94
  }
103
95
 
104
96
  async function loadIconAliases(): Promise<Record<string, string[]>> {
105
97
  if (iconAliasesCache) {return iconAliasesCache;}
106
- const aliasesPath = path.resolve(__dirname, '../../s2-docs/src/iconAliases.js');
107
- if (!fs.existsSync(aliasesPath)) {return iconAliasesCache = {};}
108
- const mod = await import(pathToFileURL(aliasesPath).href);
109
- return (iconAliasesCache = (mod.iconAliases ?? {}));
98
+ const bundled = readBundledJson('iconAliases.json');
99
+ return (iconAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
110
100
  }
111
101
 
112
102
  async function loadIllustrationAliases(): Promise<Record<string, string[]>> {
113
103
  if (illustrationAliasesCache) {return illustrationAliasesCache;}
114
- const aliasesPath = path.resolve(__dirname, '../../s2-docs/src/illustrationAliases.js');
115
- if (!fs.existsSync(aliasesPath)) {return illustrationAliasesCache = {};}
116
- const mod = await import(pathToFileURL(aliasesPath).href);
117
- return (illustrationAliasesCache = (mod.illustrationAliases ?? {}));
104
+ const bundled = readBundledJson('illustrationAliases.json');
105
+ return (illustrationAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
118
106
  }
119
107
 
120
- function readAllPagesFor(library: Library): PageInfo[] {
121
- assertDocsExist();
122
- const pattern = `${library}/**/*.md`;
123
- const absFiles = fg.sync([pattern], {cwd: DOCS_DIST_ROOT, absolute: true, suppressErrors: true});
108
+ // Whether we've loaded the page index for a library yet.
109
+ const pageIndexLoaded = new Set<Library>();
110
+
111
+ // Build a lightweight index of pages for the given library from the CDN's llms.txt.
112
+ // Populates pageCache with stubs (title from filename; description/sections omitted).
113
+ async function buildPageIndex(library: Library): Promise<PageInfo[]> {
114
+ if (pageIndexLoaded.has(library)) {
115
+ return Array.from(pageCache.values()).filter(p => p.key.startsWith(`${library}/`));
116
+ }
117
+
124
118
  const pages: PageInfo[] = [];
125
- for (const absPath of absFiles) {
126
- if (path.basename(absPath).toLowerCase() === 'llms.txt') {continue;}
127
- const rel = path.relative(DOCS_DIST_ROOT, absPath);
128
- const key = rel.replace(/\\/g, '/').replace(/\.md$/i, '');
129
- const info = parsePage(absPath, key);
119
+
120
+ // Read llms.txt to enumerate available pages without downloading them all.
121
+ const llmsUrl = `${libBaseUrl(library)}/llms.txt`;
122
+ const txt = await fetchText(llmsUrl);
123
+ const re = /^\s*-\s*\[([^\]]+)\]\(([^)]+)\)\s*$/;
124
+ for (const line of txt.split(/\r?\n/)) {
125
+ const m = line.match(re);
126
+ if (!m) {continue;}
127
+ const display = (m[1] || '').trim();
128
+ const href = (m[2] || '').trim();
129
+ if (!href || !/\.md$/i.test(href)) {continue;}
130
+ const key = href.replace(/\.md$/i, '').replace(/\\/g, '/');
131
+ const title = display || path.basename(key);
132
+ const url = `${DEFAULT_CDN_BASE}/${key}.md`;
133
+ const info: PageInfo = {key, title, description: undefined, filePath: url, sections: []};
130
134
  pages.push(info);
131
135
  pageCache.set(info.key, info);
132
136
  }
133
- return pages;
137
+
138
+ pageIndexLoaded.add(library);
139
+ return pages.sort((a, b) => a.key.localeCompare(b.key));
134
140
  }
135
141
 
136
- function parsePage(absPath: string, keyFromPath?: string): PageInfo {
137
- const raw = fs.readFileSync(absPath, 'utf8');
138
- const lines = raw.split(/\r?\n/);
142
+ function parseSectionsFromMarkdown(lines: string[]): SectionInfo[] {
143
+ const sections: SectionInfo[] = [];
144
+ let inCode = false;
145
+ for (let idx = 0; idx < lines.length; idx++) {
146
+ const line = lines[idx];
147
+ if (/^```/.test(line.trim())) {inCode = !inCode;}
148
+ if (inCode) {continue;}
149
+ if (line.startsWith('## ')) {
150
+ const name = line.replace(/^##\s+/, '').trim();
151
+ sections.push({name, startLine: idx, endLine: lines.length});
152
+ }
153
+ }
154
+ for (let s = 0; s < sections.length - 1; s++) {
155
+ sections[s].endLine = sections[s + 1].startLine;
156
+ }
157
+ return sections;
158
+ }
139
159
 
160
+ function extractTitleAndDescription(lines: string[]): {title: string, description?: string} {
140
161
  let title = '';
141
162
  let description: string | undefined = undefined;
163
+
142
164
  let i = 0;
143
- // Find first H1 (title)
144
165
  for (; i < lines.length; i++) {
145
166
  const line = lines[i];
146
167
  if (line.startsWith('# ')) {
@@ -150,7 +171,6 @@ function parsePage(absPath: string, keyFromPath?: string): PageInfo {
150
171
  }
151
172
  }
152
173
 
153
- // Collect first paragraph as description (non-empty text until blank line)
154
174
  let descLines: string[] = [];
155
175
  let inCode = false;
156
176
  for (; i < lines.length; i++) {
@@ -160,7 +180,6 @@ function parsePage(absPath: string, keyFromPath?: string): PageInfo {
160
180
  if (line.trim() === '') {
161
181
  if (descLines.length > 0) {break;} else {continue;}
162
182
  }
163
- // ignore headings and HTML-like tags if they appear before paragraph
164
183
  if (/^#{1,6}\s/.test(line) || /^</.test(line.trim())) {continue;}
165
184
  descLines.push(line);
166
185
  }
@@ -168,31 +187,26 @@ function parsePage(absPath: string, keyFromPath?: string): PageInfo {
168
187
  description = descLines.join('\n').trim();
169
188
  }
170
189
 
171
- // Parse sections (## ...)
172
- const sections: SectionInfo[] = [];
173
- inCode = false;
174
- for (let idx = 0; idx < lines.length; idx++) {
175
- const line = lines[idx];
176
- if (/^```/.test(line.trim())) {inCode = !inCode;}
177
- if (inCode) {continue;}
178
- if (line.startsWith('## ')) {
179
- const name = line.replace(/^##\s+/, '').trim();
180
- sections.push({name, startLine: idx, endLine: lines.length});
181
- }
182
- }
183
- // Compute endLine for each section as start of next section
184
- for (let s = 0; s < sections.length - 1; s++) {
185
- sections[s].endLine = sections[s + 1].startLine;
190
+ return {title, description};
191
+ }
192
+
193
+ async function ensureParsedPage(info: PageInfo): Promise<PageInfo> {
194
+ if (info.sections && info.sections.length > 0 && info.description !== undefined) {
195
+ return info;
186
196
  }
187
197
 
188
- const rel = path.relative(DOCS_DIST_ROOT, absPath).replace(/\\/g, '/');
189
- const key = keyFromPath ?? rel.replace(/\.md$/i, '');
190
- return {key, title, description, filePath: absPath, sections};
198
+ const text = await fetchText(info.filePath);
199
+ const lines = text.split(/\r?\n/);
200
+ const {title, description} = extractTitleAndDescription(lines);
201
+ const sections = parseSectionsFromMarkdown(lines);
202
+ const updated = {...info, title: title || info.title, description, sections};
203
+ pageCache.set(updated.key, updated);
204
+ return updated;
191
205
  }
192
206
 
193
- function resolvePagePathFor(library: Library, pageName: string): PageInfo {
194
- // Accept keys like "s2/Button" or plain "Button" but restrict to the selected library
195
- assertDocsExist();
207
+ async function resolvePageRef(library: Library, pageName: string): Promise<PageInfo> {
208
+ // Ensure index is loaded
209
+ await buildPageIndex(library);
196
210
 
197
211
  if (pageCache.has(pageName)) {
198
212
  return pageCache.get(pageName)!;
@@ -204,27 +218,21 @@ function resolvePagePathFor(library: Library, pageName: string): PageInfo {
204
218
  if (prefix !== library) {
205
219
  throw new Error(`Page '${pageName}' is not in the '${library}' library.`);
206
220
  }
207
- const abs = path.join(DOCS_DIST_ROOT, `${normalized}.md`);
208
- if (!fs.existsSync(abs)) {
209
- throw new Error(`Page not found: ${pageName}`);
210
- }
211
- const info = parsePage(abs, normalized);
212
- pageCache.set(normalized, info);
213
- return info;
221
+ const maybe = pageCache.get(normalized);
222
+ if (maybe) {return maybe;}
223
+ const filePath = `${DEFAULT_CDN_BASE}/${normalized}.md`;
224
+ const stub: PageInfo = {key: normalized, title: path.basename(normalized), description: undefined, filePath, sections: []};
225
+ pageCache.set(stub.key, stub);
226
+ return stub;
214
227
  }
215
228
 
216
- const abs = path.join(DOCS_DIST_ROOT, library, `${pageName}.md`);
217
- if (!fs.existsSync(abs)) {
218
- throw new Error(`Page not found in '${library}': ${pageName}`);
219
- }
220
229
  const key = `${library}/${pageName}`;
221
- const info = parsePage(abs, key);
222
- pageCache.set(info.key, info);
223
- return info;
224
- }
225
-
226
- function readPageContent(filePath: string): string {
227
- return fs.readFileSync(filePath, 'utf8');
230
+ const maybe = pageCache.get(key);
231
+ if (maybe) {return maybe;}
232
+ const filePath = `${DEFAULT_CDN_BASE}/${key}.md`;
233
+ const stub: PageInfo = {key, title: pageName, description: undefined, filePath, sections: []};
234
+ pageCache.set(stub.key, stub);
235
+ return stub;
228
236
  }
229
237
 
230
238
  async function startServer(library: Library) {
@@ -233,6 +241,13 @@ async function startServer(library: Library) {
233
241
  version: '0.1.0'
234
242
  });
235
243
 
244
+ // Build page index at startup.
245
+ try {
246
+ await buildPageIndex(library);
247
+ } catch (e) {
248
+ console.warn(`Warning: failed to load ${library} docs index (${errorToString(e)}).`);
249
+ }
250
+
236
251
  // list_pages tool
237
252
  server.registerTool(
238
253
  'list_pages',
@@ -242,7 +257,7 @@ async function startServer(library: Library) {
242
257
  inputSchema: {includeDescription: z.boolean().optional()}
243
258
  },
244
259
  async ({includeDescription}) => {
245
- const pages = readAllPagesFor(library);
260
+ const pages = await buildPageIndex(library);
246
261
  const items = pages
247
262
  .sort((a, b) => a.key.localeCompare(b.key))
248
263
  .map(p => includeDescription ? {key: p.key, title: p.title, description: p.description ?? ''} : {key: p.key, title: p.title});
@@ -261,7 +276,8 @@ async function startServer(library: Library) {
261
276
  inputSchema: {page_name: z.string()}
262
277
  },
263
278
  async ({page_name}) => {
264
- const info = resolvePagePathFor(library, page_name);
279
+ const ref = await resolvePageRef(library, page_name);
280
+ const info = await ensureParsedPage(ref);
265
281
  const out = {
266
282
  key: info.key,
267
283
  title: info.title,
@@ -281,24 +297,26 @@ async function startServer(library: Library) {
281
297
  inputSchema: {page_name: z.string(), section_name: z.string().optional()}
282
298
  },
283
299
  async ({page_name, section_name}) => {
284
- const info = resolvePagePathFor(library, page_name);
300
+ const ref = await resolvePageRef(library, page_name);
285
301
  let text: string;
302
+ text = await fetchText(ref.filePath);
303
+
286
304
  if (!section_name) {
287
- text = readPageContent(info.filePath);
288
- } else {
289
- // Find section by exact title match (case-sensitive first, then case-insensitive)
290
- let section = info.sections.find(s => s.name === section_name);
291
- if (!section) {
292
- section = info.sections.find(s => s.name.toLowerCase() === section_name.toLowerCase());
293
- }
294
- if (!section) {
295
- const available = info.sections.map(s => s.name).join(', ');
296
- throw new Error(`Section '${section_name}' not found in ${info.key}. Available: ${available}`);
297
- }
298
- const lines = fs.readFileSync(info.filePath, 'utf8').split(/\r?\n/);
299
- text = lines.slice(section.startLine, section.endLine).join('\n');
305
+ return {content: [{type: 'text', text}]} as const;
306
+ }
307
+
308
+ const lines = text.split(/\r?\n/);
309
+ const sections = parseSectionsFromMarkdown(lines);
310
+ let section = sections.find(s => s.name === section_name);
311
+ if (!section) {
312
+ section = sections.find(s => s.name.toLowerCase() === section_name.toLowerCase());
313
+ }
314
+ if (!section) {
315
+ const available = sections.map(s => s.name).join(', ');
316
+ throw new Error(`Section '${section_name}' not found in ${ref.key}. Available: ${available}`);
300
317
  }
301
- return {content: [{type: 'text', text}]} as const;
318
+ const snippet = lines.slice(section.startLine, section.endLine).join('\n');
319
+ return {content: [{type: 'text', text: snippet}]} as const;
302
320
  }
303
321
  );
304
322
 
@@ -383,7 +401,7 @@ async function startServer(library: Library) {
383
401
  }
384
402
 
385
403
  function printUsage() {
386
- const usage = 'Usage: mcp <subcommand>\n\nSubcommands:\n s2 Start MCP server for React Spectrum S2 docs\n react-aria Start MCP server for React Aria docs\n\nExamples:\n npx @react-spectrum/mcp s2\n npx @react-spectrum/mcp react-aria';
404
+ const usage = 'Usage: mcp <subcommand>\n\nSubcommands:\n s2 Start MCP server for React Spectrum S2 docs\n react-aria Start MCP server for React Aria docs\n\nEnvironment:\n\nExamples:\n npx @react-spectrum/mcp s2\n npx @react-spectrum/mcp react-aria';
387
405
  console.log(usage);
388
406
  }
389
407