@react-spectrum/mcp 3.0.0-nightly-df85b152a-250927 → 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.
- package/README.md +127 -0
- package/package.json +2 -2
- 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
|
+
[](https://cursor.com/en/install-mcp?name=s2-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgczIifQ%3D%3D)
|
|
32
|
+
|
|
33
|
+
React Aria:
|
|
34
|
+
|
|
35
|
+
[](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-
|
|
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
|
-
|
|
3
|
-
import {fileURLToPath
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
115
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
137
|
+
|
|
138
|
+
pageIndexLoaded.add(library);
|
|
139
|
+
return pages.sort((a, b) => a.key.localeCompare(b.key));
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
function
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
189
|
-
const
|
|
190
|
-
|
|
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
|
|
194
|
-
//
|
|
195
|
-
|
|
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
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|