diff --git a/eslint.config.mjs b/eslint.config.mjs
index 92f49f59acc..107d3081616 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -60,7 +60,8 @@ export default [{
"packages/dev/parcel-transformer-storybook/*",
"packages/dev/storybook-builder-parcel/*",
"packages/dev/storybook-react-parcel/*",
- "packages/dev/s2-docs/pages/**"
+ "packages/dev/s2-docs/pages/**",
+ "packages/dev/mcp/*/dist"
],
}, ...compat.extends("eslint:recommended"), {
plugins: {
diff --git a/package.json b/package.json
index 6fde5bad643..3d4dfe3fd9a 100644
--- a/package.json
+++ b/package.json
@@ -27,8 +27,8 @@
"build:docs": "DOCS_ENV=staging parcel build 'packages/@react-{spectrum,aria,stately}/*/docs/*.mdx' 'packages/react-aria-components/docs/**/*.mdx' 'packages/@internationalized/*/docs/*.mdx' 'packages/dev/docs/pages/**/*.mdx'",
"start:s2-docs": "yarn workspace @react-spectrum/s2-docs start",
"build:s2-docs": "yarn workspace @react-spectrum/s2-docs build",
- "build:mcp": "yarn workspace @react-spectrum/mcp build",
- "start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn workspace @react-spectrum/mcp build && node packages/dev/mcp/dist/index.js",
+ "build:mcp": "yarn workspace @react-spectrum/mcp build && yarn workspace @react-aria/mcp build",
+ "start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn build:mcp && node packages/dev/mcp/s2/dist/index.js && node packages/dev/mcp/react-aria/dist/index.js",
"test:mcp": "yarn build:s2-docs && yarn build:mcp && node packages/dev/mcp/scripts/smoke-list-pages.mjs",
"test": "cross-env STRICT_MODE=1 VIRT_ON=1 yarn jest",
"test:lint": "node packages/**/*.test-lint.js",
@@ -66,6 +66,8 @@
"packages/react-aria",
"packages/react-aria-components",
"packages/tailwindcss-react-aria-components",
+ "packages/dev/mcp/s2",
+ "packages/dev/mcp/react-aria",
"packages/*/*"
],
"devDependencies": {
diff --git a/packages/dev/mcp/README.md b/packages/dev/mcp/README.md
deleted file mode 100644
index 258a785a298..00000000000
--- a/packages/dev/mcp/README.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# @react-spectrum/mcp
-
-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.
-
-## Using with an MCP client
-
-Add one or both servers to your MCP client configuration (the exact file and schema may depend on your client).
-
-```json
-{
- "mcpServers": {
- "s2-docs": {
- "command": "npx",
- "args": ["@react-spectrum/mcp", "s2"]
- },
- "react-aria-docs": {
- "command": "npx",
- "args": ["@react-spectrum/mcp", "react-aria"]
- }
- }
-}
-```
-
-
-Cursor
-
-#### Click the button to install:
-
-React Spectrum (S2):
-
-[](https://cursor.com/en/install-mcp?name=s2-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgczIifQ%3D%3D)
-
-React Aria:
-
-[](https://cursor.com/en/install-mcp?name=react-aria-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgcmVhY3QtYXJpYSJ9)
-
-Or follow the MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
-
-
-
-
-VS Code
-
-#### Click the button to install:
-
-React Spectrum (S2):
-
-[
](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)
-
-React Aria:
-
-[
](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)
-
-
-#### Or install manually:
-
-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:
-
-```bash
-# For VS Code
-code --add-mcp '{"name":"s2-docs","command":"npx","args":["@react-spectrum/mcp","s2"]}'
-code --add-mcp '{"name":"react-aria-docs","command":"npx","args":["@react-spectrum/mcp","react-aria"]}'
-```
-
-
-
-
-Claude Code
-
-Use the Claude Code CLI to add the servers:
-
-```bash
-claude mcp add s2-docs npx @react-spectrum/mcp s2
-claude mcp add react-aria-docs npx @react-spectrum/mcp react-aria
-```
-For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
-
-
-
-Codex
-
-Create or edit the configuration file `~/.codex/config.toml` and add:
-
-```toml
-[mcp_servers.s2-docs]
-command = "npx"
-args = ["@react-spectrum/mcp", "s2"]
-
-[mcp_servers.react-aria-docs]
-command = "npx"
-args = ["@react-spectrum/mcp", "react-aria"]
-```
-
-For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
-
-
-
-
-Gemini CLI
-
-Use the Gemini CLI to add the servers:
-
-```bash
-gemini mcp add s2-docs npx @react-spectrum/mcp s2
-gemini mcp add react-aria-docs npx @react-spectrum/mcp react-aria
-```
-
-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).
-
-
-
-
-Windsurf
-
-Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above.
-
-
-
-## Tools
-
-### React Spectrum (S2)
-
-| Tool | Input | Description |
-| --- | --- | --- |
-| `list_s2_pages` | `{ includeDescription?: boolean }` | List available pages in the S2 docs. |
-| `get_s2_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
-| `get_s2_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
-| `search_s2_icons` | `{ terms: string or string[] }` | Search S2 workflow icon names. |
-| `search_s2_illustrations` | `{ terms: string or string[] }` | Search S2 illustration names. |
-
-### React Aria
-
-| Tool | Input | Description |
-| --- | --- | --- |
-| `list_react_aria_pages` | `{ includeDescription?: boolean }` | List available pages in the React Aria docs. |
-| `get_react_aria_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
-| `get_react_aria_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
-
-## Development
-
-### Testing locally
-
-Build the docs and MCP server locally, then start the docs server.
-
-```bash
-yarn workspace @react-spectrum/s2-docs generate:md
-yarn workspace @react-spectrum/mcp build
-yarn start:s2-docs
-```
-
-Update your MCP client configuration to use the local MCP server:
-
-```json
-{
- "mcpServers": {
- "React Spectrum (S2)": {
- "command": "node",
- "args": ["{your path here}/react-spectrum/packages/dev/mcp/dist/index.js", "s2"],
- "env": {
- "DOCS_CDN_BASE": "http://localhost:1234"
- }
- },
- "React Aria": {
- "command": "node",
- "args": ["{your path here}/react-spectrum/packages/dev/mcp/dist/index.js", "react-aria"],
- "env": {
- "DOCS_CDN_BASE": "http://localhost:1234"
- }
- }
- }
-}
-```
diff --git a/packages/dev/mcp/react-aria/README.md b/packages/dev/mcp/react-aria/README.md
new file mode 100644
index 00000000000..1769215c8db
--- /dev/null
+++ b/packages/dev/mcp/react-aria/README.md
@@ -0,0 +1,138 @@
+# @react-aria/mcp
+
+The `@react-aria/mcp` package provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for React Aria documentation. It exposes a set of tools that MCP clients can discover and call to browse the docs.
+
+## Installation
+
+### Quick Start
+
+Simply run the server using npx:
+
+```bash
+npx @react-aria/mcp
+```
+
+### Using with an MCP client
+
+Add the server to your MCP client configuration (the exact file and schema may depend on your client).
+
+```json
+{
+ "mcpServers": {
+ "React Aria": {
+ "command": "npx",
+ "args": ["@react-aria/mcp"]
+ }
+ }
+}
+```
+
+
+Cursor
+
+#### Click the button to install:
+
+[](cursor://anysphere.cursor-deeplink/mcp/install?name=React%20Aria&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcmVhY3QtYXJpYS9tY3AiXX0%3D)
+
+Or follow the MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
+
+
+
+
+VS Code
+
+#### Click the button to install:
+
+[
](vscode:mcp/install?%7B%22name%22%3A%22React%20Aria%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40react-aria%2Fmcp%22%5D%7D)
+
+#### Or install manually:
+
+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 the server using the VS Code CLI:
+
+```bash
+code --add-mcp '{"name":"React Aria","command":"npx","args":["@react-aria/mcp"]}'
+```
+
+
+
+
+Claude Code
+
+Use the Claude Code CLI to add the server:
+
+```bash
+claude mcp add react-aria npx @react-aria/mcp
+```
+For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
+
+
+
+Codex
+
+Create or edit the configuration file `~/.codex/config.toml` and add:
+
+```toml
+[mcp_servers.react-aria]
+command = "npx"
+args = ["@react-aria/mcp"]
+```
+
+For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
+
+
+
+
+Gemini CLI
+
+Use the Gemini CLI to add the server:
+
+```bash
+gemini mcp add react-aria npx @react-aria/mcp
+```
+
+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).
+
+
+
+
+Windsurf
+
+Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above.
+
+
+
+## Tools
+
+| Tool | Input | Description |
+| --- | --- | --- |
+| `list_react_aria_pages` | `{ includeDescription?: boolean }` | List available pages in the React Aria docs. |
+| `get_react_aria_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
+| `get_react_aria_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
+
+## Development
+
+### Testing locally
+
+Build the docs and MCP server locally, then start the docs server.
+
+```bash
+yarn workspace @react-spectrum/s2-docs generate:md
+yarn workspace @react-aria/mcp build
+yarn start:s2-docs
+```
+
+Update your MCP client configuration to use the local MCP server:
+
+```json
+{
+ "mcpServers": {
+ "React Aria": {
+ "command": "node",
+ "args": ["{your path here}/react-spectrum/packages/dev/mcp/react-aria/dist/index.js"],
+ "env": {
+ "DOCS_CDN_BASE": "http://localhost:1234"
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/packages/dev/mcp/react-aria/package.json b/packages/dev/mcp/react-aria/package.json
new file mode 100644
index 00000000000..0c6872a17c3
--- /dev/null
+++ b/packages/dev/mcp/react-aria/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@react-aria/mcp",
+ "version": "0.1.0",
+ "description": "MCP server for React Aria documentation",
+ "type": "module",
+ "bin": "dist/react-aria/src/index.js",
+ "scripts": {
+ "prepublishOnly": "yarn build",
+ "build": "tsc -p tsconfig.json",
+ "start": "node dist/react-aria/src/index.js",
+ "dev": "node --enable-source-maps dist/react-aria/src/index.js"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.17.3",
+ "@swc/helpers": "^0.5.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/adobe/react-spectrum"
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "sideEffects": false,
+ "main": "dist/main.js",
+ "module": "dist/module.js",
+ "types": "dist/types.d.ts",
+ "source": "src/index.ts"
+}
diff --git a/packages/dev/mcp/scripts/smoke-list-pages.mjs b/packages/dev/mcp/react-aria/scripts/smoke-list-pages.mjs
similarity index 100%
rename from packages/dev/mcp/scripts/smoke-list-pages.mjs
rename to packages/dev/mcp/react-aria/scripts/smoke-list-pages.mjs
diff --git a/packages/dev/mcp/react-aria/src/index.ts b/packages/dev/mcp/react-aria/src/index.ts
new file mode 100644
index 00000000000..170113e1388
--- /dev/null
+++ b/packages/dev/mcp/react-aria/src/index.ts
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+///
+import {errorToString} from '../../shared/src/utils.js';
+import {startServer} from '../../shared/src/server.js';
+
+// CLI entry for React Aria
+(async () => {
+ try {
+ const arg = (process.argv[2] || '').trim();
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
+ console.log('Usage: npx @react-aria/mcp\n\nStarts the MCP server for React Aria documentation.');
+ process.exit(0);
+ }
+ await startServer('react-aria', '0.1.0');
+ } catch (err) {
+ console.error(errorToString(err));
+ process.exit(1);
+ }
+})();
diff --git a/packages/dev/mcp/react-aria/tsconfig.json b/packages/dev/mcp/react-aria/tsconfig.json
new file mode 100644
index 00000000000..3f0a13a0930
--- /dev/null
+++ b/packages/dev/mcp/react-aria/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "..",
+ "skipLibCheck": true,
+ "types": ["node"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "es2020",
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": false
+ },
+ "include": [
+ "src/**/*",
+ "../shared/src/**/*"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules"
+ ]
+}
diff --git a/packages/dev/mcp/s2/README.md b/packages/dev/mcp/s2/README.md
new file mode 100644
index 00000000000..ac9f203d7ed
--- /dev/null
+++ b/packages/dev/mcp/s2/README.md
@@ -0,0 +1,140 @@
+# @react-spectrum/mcp
+
+The `@react-spectrum/mcp` package provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for React Spectrum (S2) documentation. It exposes a set of tools that MCP clients can discover and call to browse the docs, search for icons and illustrations, and more.
+
+## Installation
+
+### Quick Start
+
+Simply run the server using npx:
+
+```bash
+npx @react-spectrum/mcp
+```
+
+### Using with an MCP client
+
+Add the server to your MCP client configuration (the exact file and schema may depend on your client).
+
+```json
+{
+ "mcpServers": {
+ "React Spectrum (S2)": {
+ "command": "npx",
+ "args": ["@react-spectrum/mcp"]
+ }
+ }
+}
+```
+
+
+Cursor
+
+#### Click the button to install:
+
+[](cursor://anysphere.cursor-deeplink/mcp/install?name=React%20Spectrum%20(S2)&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcmVhY3Qtc3BlY3RydW0vbWNwIl19)
+
+Or follow the MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
+
+
+
+
+VS Code
+
+#### Click the button to install:
+
+[
](vscode:mcp/install?%7B%22name%22%3A%22React%20Spectrum%20(S2)%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40react-spectrum%2Fmcp%22%5D%7D)
+
+#### Or install manually:
+
+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 the server using the VS Code CLI:
+
+```bash
+code --add-mcp '{"name":"React Spectrum (S2)","command":"npx","args":["@react-spectrum/mcp"]}'
+```
+
+
+
+
+Claude Code
+
+Use the Claude Code CLI to add the server:
+
+```bash
+claude mcp add react-spectrum-s2 npx @react-spectrum/mcp
+```
+For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
+
+
+
+Codex
+
+Create or edit the configuration file `~/.codex/config.toml` and add:
+
+```toml
+[mcp_servers.react-spectrum-s2]
+command = "npx"
+args = ["@react-spectrum/mcp"]
+```
+
+For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
+
+
+
+
+Gemini CLI
+
+Use the Gemini CLI to add the server:
+
+```bash
+gemini mcp add react-spectrum-s2 npx @react-spectrum/mcp
+```
+
+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).
+
+
+
+
+Windsurf
+
+Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp) and use the standard config above.
+
+
+
+## Tools
+
+| Tool | Input | Description |
+| --- | --- | --- |
+| `list_s2_pages` | `{ includeDescription?: boolean }` | List available pages in the S2 docs. |
+| `get_s2_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
+| `get_s2_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
+| `search_s2_icons` | `{ terms: string \| string[] }` | Search S2 workflow icon names. |
+| `search_s2_illustrations` | `{ terms: string \| string[] }` | Search S2 illustration names. |
+
+## Development
+
+### Testing locally
+
+Build the docs and MCP server locally, then start the docs server.
+
+```bash
+yarn workspace @react-spectrum/s2-docs generate:md
+yarn workspace @react-spectrum/mcp build
+yarn start:s2-docs
+```
+
+Update your MCP client configuration to use the local MCP server:
+
+```json
+{
+ "mcpServers": {
+ "React Spectrum (S2)": {
+ "command": "node",
+ "args": ["{your path here}/react-spectrum/packages/dev/mcp/s2/dist/s2/src/index.js"],
+ "env": {
+ "DOCS_CDN_BASE": "http://localhost:1234"
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/packages/dev/mcp/package.json b/packages/dev/mcp/s2/package.json
similarity index 76%
rename from packages/dev/mcp/package.json
rename to packages/dev/mcp/s2/package.json
index 2719984b267..07e833e8dde 100644
--- a/packages/dev/mcp/package.json
+++ b/packages/dev/mcp/s2/package.json
@@ -1,14 +1,14 @@
{
"name": "@react-spectrum/mcp",
"version": "0.1.0",
- "description": "MCP server for React Spectrum (S2) and React Aria documentation",
+ "description": "MCP server for React Spectrum (S2) documentation",
"type": "module",
- "bin": "dist/index.js",
+ "bin": "dist/s2/src/index.js",
"scripts": {
"prepublishOnly": "yarn build",
"build": "node ./scripts/build-data.mjs && tsc -p tsconfig.json",
- "start": "node dist/index.js",
- "dev": "node --enable-source-maps dist/index.js"
+ "start": "node dist/s2/src/index.js",
+ "dev": "node --enable-source-maps dist/s2/src/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.3",
@@ -30,15 +30,15 @@
"type": "git",
"url": "https://github.com/adobe/react-spectrum"
},
- "main": "dist/main.js",
- "module": "dist/module.js",
- "types": "dist/types.d.ts",
- "source": "src/index.ts",
"files": [
"dist",
"src"
],
"sideEffects": [
"*.css"
- ]
+ ],
+ "main": "dist/main.js",
+ "module": "dist/module.js",
+ "types": "dist/types.d.ts",
+ "source": "src/index.ts"
}
diff --git a/packages/dev/mcp/scripts/build-data.mjs b/packages/dev/mcp/s2/scripts/build-data.mjs
similarity index 74%
rename from packages/dev/mcp/scripts/build-data.mjs
rename to packages/dev/mcp/s2/scripts/build-data.mjs
index 170a4bc9e26..8145ee77aa6 100644
--- a/packages/dev/mcp/scripts/build-data.mjs
+++ b/packages/dev/mcp/s2/scripts/build-data.mjs
@@ -6,7 +6,7 @@ import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const REPO_ROOT = path.resolve(__dirname, '../../../..');
+const REPO_ROOT = path.resolve(__dirname, '../../../../..');
const OUT_DIR = path.resolve(__dirname, '../dist/data');
const ICONS_DIR = path.resolve(REPO_ROOT, 'packages/@react-spectrum/s2/s2wf-icons');
@@ -25,8 +25,13 @@ function writeJson(file, data) {
}
function buildIconNames() {
- if (!fs.existsSync(ICONS_DIR)) {return null;}
+ if (!fs.existsSync(ICONS_DIR)) {
+ throw new Error(`Icons directory not found: ${ICONS_DIR}`);
+ }
const files = fg.sync('*.svg', {cwd: ICONS_DIR, absolute: false, suppressErrors: true});
+ if (files.length === 0) {
+ throw new Error(`No icon SVG files found in: ${ICONS_DIR}`);
+ }
const ids = Array.from(new Set(
files.map(f => f.replace(/\.svg$/i, '').replace(/^S2_Icon_(.*?)(Size\d+)?_2.*/, '$1'))
)).sort((a, b) => a.localeCompare(b));
@@ -34,8 +39,13 @@ function buildIconNames() {
}
function buildIllustrationNames() {
- if (!fs.existsSync(ILLUSTRATIONS_DIR)) {return null;}
+ if (!fs.existsSync(ILLUSTRATIONS_DIR)) {
+ throw new Error(`Illustrations directory not found: ${ILLUSTRATIONS_DIR}`);
+ }
const files = fg.sync('**/*.svg', {cwd: ILLUSTRATIONS_DIR, absolute: false, suppressErrors: true});
+ if (files.length === 0) {
+ throw new Error(`No illustration SVG files found in: ${ILLUSTRATIONS_DIR}`);
+ }
const ids = Array.from(new Set(
files.map(f => {
const base = f.replace(/\.svg$/i, '').replace(/^S2_lin_(.*)_\d+$/, '$1');
@@ -57,18 +67,10 @@ async function main() {
const iconAliases = await loadAliases(ICON_ALIASES_JS, 'iconAliases');
const illustrationAliases = await loadAliases(ILLUSTRATION_ALIASES_JS, 'illustrationAliases');
- if (icons && icons.length) {
- writeJson(path.join(OUT_DIR, 'icons.json'), icons);
- }
- if (illustrations && illustrations.length) {
- writeJson(path.join(OUT_DIR, 'illustrations.json'), illustrations);
- }
- if (iconAliases && Object.keys(iconAliases).length) {
- writeJson(path.join(OUT_DIR, 'iconAliases.json'), iconAliases);
- }
- if (illustrationAliases && Object.keys(illustrationAliases).length) {
- writeJson(path.join(OUT_DIR, 'illustrationAliases.json'), illustrationAliases);
- }
+ writeJson(path.join(OUT_DIR, 'icons.json'), icons);
+ writeJson(path.join(OUT_DIR, 'illustrations.json'), illustrations);
+ writeJson(path.join(OUT_DIR, 'iconAliases.json'), iconAliases);
+ writeJson(path.join(OUT_DIR, 'illustrationAliases.json'), illustrationAliases);
}
main().catch((err) => {
diff --git a/packages/dev/mcp/s2/scripts/smoke-list-pages.mjs b/packages/dev/mcp/s2/scripts/smoke-list-pages.mjs
new file mode 100644
index 00000000000..d666846d990
--- /dev/null
+++ b/packages/dev/mcp/s2/scripts/smoke-list-pages.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import {Client} from '@modelcontextprotocol/sdk/client/index.js';
+import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
+
+async function main() {
+ const subcommand = process.argv[2] || 's2';
+ const transport = new StdioClientTransport({
+ command: 'node',
+ args: [new URL('../dist/index.js', import.meta.url).pathname, subcommand]
+ });
+
+ const client = new Client({name: 's2-docs-smoke', version: '0.0.0'});
+ await client.connect(transport);
+
+ const result = await client.callTool({
+ name: 'list_pages',
+ arguments: {includeDescription: true}
+ });
+
+ const text = result?.content?.[0]?.text ?? '';
+ console.log(text);
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error(err?.stack || String(err));
+ process.exit(1);
+});
diff --git a/packages/dev/mcp/s2/src/index.ts b/packages/dev/mcp/s2/src/index.ts
new file mode 100644
index 00000000000..1e6e76fa161
--- /dev/null
+++ b/packages/dev/mcp/s2/src/index.ts
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+///
+import {errorToString} from '../../shared/src/utils.js';
+import {listIconNames, listIllustrationNames, loadIconAliases, loadIllustrationAliases} from './s2-data.js';
+import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
+import {startServer} from '../../shared/src/server.js';
+import {z} from 'zod';
+
+// CLI entry for S2
+(async () => {
+ try {
+ const arg = (process.argv[2] || '').trim();
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
+ console.log('Usage: npx @react-spectrum/mcp\n\nStarts the MCP server for React Spectrum (S2) documentation.');
+ process.exit(0);
+ }
+
+ await startServer('s2', '0.1.0', (server: McpServer) => {
+ server.registerTool(
+ 'search_s2_icons',
+ {
+ title: 'Search S2 icons',
+ description: 'Searches the S2 workflow icon set by one or more terms; returns matching icon names.',
+ inputSchema: {terms: z.union([z.string(), z.array(z.string())])}
+ },
+ async ({terms}) => {
+ const allNames = listIconNames();
+ const nameSet = new Set(allNames);
+ const aliases = await loadIconAliases();
+ const rawTerms = Array.isArray(terms) ? terms : [terms];
+ const normalized = Array.from(new Set(rawTerms.map(t => String(t ?? '').trim().toLowerCase()).filter(Boolean)));
+ if (normalized.length === 0) {
+ throw new Error('Provide at least one non-empty search term.');
+ }
+ // direct name matches
+ const results = new Set(allNames.filter(name => {
+ const nameLower = name.toLowerCase();
+ return normalized.some(term => nameLower.includes(term));
+ }));
+ // alias matches
+ for (const [aliasKey, targets] of Object.entries(aliases)) {
+ if (!targets || targets.length === 0) {continue;}
+ const aliasLower = aliasKey.toLowerCase();
+ if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
+ for (const t of targets) {
+ const n = String(t);
+ if (nameSet.has(n)) {results.add(n);}
+ }
+ }
+ }
+ return {content: [{type: 'text', text: JSON.stringify(Array.from(results).sort((a, b) => a.localeCompare(b)), null, 2)}]};
+ }
+ );
+
+ server.registerTool(
+ 'search_s2_illustrations',
+ {
+ title: 'Search S2 illustrations',
+ description: 'Searches the S2 illustrations set by one or more terms; returns matching illustration names.',
+ inputSchema: {terms: z.union([z.string(), z.array(z.string())])}
+ },
+ async ({terms}) => {
+ const allNames = listIllustrationNames();
+ const nameSet = new Set(allNames);
+ const aliases = await loadIllustrationAliases();
+ const rawTerms = Array.isArray(terms) ? terms : [terms];
+ const normalized = Array.from(new Set(rawTerms.map(t => String(t ?? '').trim().toLowerCase()).filter(Boolean)));
+ if (normalized.length === 0) {
+ throw new Error('Provide at least one non-empty search term.');
+ }
+ // direct name matches
+ const results = new Set(allNames.filter(name => {
+ const nameLower = name.toLowerCase();
+ return normalized.some(term => nameLower.includes(term));
+ }));
+ // alias matches
+ for (const [aliasKey, targets] of Object.entries(aliases)) {
+ if (!targets || targets.length === 0) {continue;}
+ const aliasLower = aliasKey.toLowerCase();
+ if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
+ for (const t of targets) {
+ const n = String(t);
+ if (nameSet.has(n)) {results.add(n);}
+ }
+ }
+ }
+ return {content: [{type: 'text', text: JSON.stringify(Array.from(results).sort((a, b) => a.localeCompare(b)), null, 2)}]};
+ }
+ );
+ });
+ } catch (err) {
+ console.error(errorToString(err));
+ process.exit(1);
+ }
+})();
diff --git a/packages/dev/mcp/s2/src/s2-data.ts b/packages/dev/mcp/s2/src/s2-data.ts
new file mode 100644
index 00000000000..8b37337cf18
--- /dev/null
+++ b/packages/dev/mcp/s2/src/s2-data.ts
@@ -0,0 +1,47 @@
+import {fileURLToPath} from 'url';
+import fs from 'fs';
+import path from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+let iconIdCache: string[] | null = null;
+let illustrationIdCache: string[] | null = null;
+let iconAliasesCache: Record | null = null;
+let illustrationAliasesCache: Record | null = null;
+
+function readBundledJson(filename: string): any | null {
+ try {
+ // Go up from s2/src/ to dist/, then to data/
+ const p = path.resolve(__dirname, '..', '..', 'data', filename);
+ if (!fs.existsSync(p)) {return null;}
+ const txt = fs.readFileSync(p, 'utf8');
+ return JSON.parse(txt);
+ } catch {
+ return null;
+ }
+}
+
+export function listIconNames(): string[] {
+ if (iconIdCache) {return iconIdCache;}
+ const bundled = readBundledJson('icons.json');
+ return (iconIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
+}
+
+export function listIllustrationNames(): string[] {
+ if (illustrationIdCache) {return illustrationIdCache;}
+ const bundled = readBundledJson('illustrations.json');
+ return (illustrationIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
+}
+
+export async function loadIconAliases(): Promise> {
+ if (iconAliasesCache) {return iconAliasesCache;}
+ const bundled = readBundledJson('iconAliases.json');
+ return (iconAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
+}
+
+export async function loadIllustrationAliases(): Promise> {
+ if (illustrationAliasesCache) {return illustrationAliasesCache;}
+ const bundled = readBundledJson('illustrationAliases.json');
+ return (illustrationAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
+}
diff --git a/packages/dev/mcp/s2/tsconfig.json b/packages/dev/mcp/s2/tsconfig.json
new file mode 100644
index 00000000000..3f0a13a0930
--- /dev/null
+++ b/packages/dev/mcp/s2/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "..",
+ "skipLibCheck": true,
+ "types": ["node"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "es2020",
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "noEmit": false
+ },
+ "include": [
+ "src/**/*",
+ "../shared/src/**/*"
+ ],
+ "exclude": [
+ "dist",
+ "node_modules"
+ ]
+}
diff --git a/packages/dev/mcp/shared/package.json b/packages/dev/mcp/shared/package.json
new file mode 100644
index 00000000000..efdf5891e28
--- /dev/null
+++ b/packages/dev/mcp/shared/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "mcp-shared",
+ "private": true,
+ "version": "0.1.0",
+ "description": "Shared code for MCP servers",
+ "type": "module",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.17.3",
+ "@swc/helpers": "^0.5.0",
+ "fast-glob": "^3.3.3",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.2"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/adobe/react-spectrum"
+ },
+ "main": "dist/main.js",
+ "module": "dist/module.js",
+ "types": "dist/types.d.ts",
+ "source": "src/index.ts",
+ "files": [
+ "dist",
+ "src"
+ ],
+ "sideEffects": false
+}
diff --git a/packages/dev/mcp/shared/src/page-manager.ts b/packages/dev/mcp/shared/src/page-manager.ts
new file mode 100644
index 00000000000..c89b09d2554
--- /dev/null
+++ b/packages/dev/mcp/shared/src/page-manager.ts
@@ -0,0 +1,89 @@
+import {DEFAULT_CDN_BASE, fetchText} from './utils.js';
+import {extractNameAndDescription, parseSectionsFromMarkdown} from './parser.js';
+import type {Library, PageInfo} from './types.js';
+import path from 'path';
+
+// Cache of parsed pages
+const pageCache = new Map();
+
+// Whether we've loaded the page index for a library yet.
+const pageIndexLoaded = new Set();
+
+function libBaseUrl(library: Library) {
+ return `${DEFAULT_CDN_BASE}/${library}`;
+}
+
+// Build an index of pages for the given library from the CDN's llms.txt.
+export async function buildPageIndex(library: Library): Promise {
+ if (pageIndexLoaded.has(library)) {
+ return Array.from(pageCache.values()).filter(p => p.key.startsWith(`${library}/`));
+ }
+
+ const pages: PageInfo[] = [];
+
+ // Read llms.txt to enumerate available pages without downloading them all.
+ const llmsUrl = `${libBaseUrl(library)}/llms.txt`;
+ const txt = await fetchText(llmsUrl);
+ const re = /^\s*-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*:\s*(.*))?\s*$/;
+ for (const line of txt.split(/\r?\n/)) {
+ const m = line.match(re);
+ if (!m) {continue;}
+ const display = (m[1] || '').trim();
+ const href = (m[2] || '').trim();
+ const description = (m[3] || '').trim() || undefined;
+ if (!href || !/\.md$/i.test(href)) {continue;}
+ const key = href.replace(/\.md$/i, '').replace(/\\/g, '/');
+ const name = display || path.basename(key);
+ const filePath = `${DEFAULT_CDN_BASE}/${key}.md`;
+ const info: PageInfo = {key, name, description, filePath, sections: []};
+ pages.push(info);
+ pageCache.set(info.key, info);
+ }
+
+ pageIndexLoaded.add(library);
+ return pages.sort((a, b) => a.key.localeCompare(b.key));
+}
+
+export async function ensureParsedPage(info: PageInfo): Promise {
+ if (info.sections && info.sections.length > 0 && info.description !== undefined) {
+ return info;
+ }
+
+ const text = await fetchText(info.filePath);
+ const lines = text.split(/\r?\n/);
+ const {name, description} = extractNameAndDescription(lines);
+ const sections = parseSectionsFromMarkdown(lines);
+ const updated = {...info, name: name || info.name, description, sections};
+ pageCache.set(updated.key, updated);
+ return updated;
+}
+
+export async function resolvePageRef(library: Library, pageName: string): Promise {
+ await buildPageIndex(library);
+
+ if (pageCache.has(pageName)) {
+ return pageCache.get(pageName)!;
+ }
+
+ if (pageName.includes('/')) {
+ const normalized = pageName.replace(/\\/g, '/');
+ const prefix = normalized.split('/', 1)[0];
+ if (prefix !== library) {
+ throw new Error(`Page '${pageName}' is not in the '${library}' library.`);
+ }
+ const maybe = pageCache.get(normalized);
+ if (maybe) {return maybe;}
+ const filePath = `${DEFAULT_CDN_BASE}/${normalized}.md`;
+ const stub: PageInfo = {key: normalized, name: path.basename(normalized), description: undefined, filePath, sections: []};
+ pageCache.set(stub.key, stub);
+ return stub;
+ }
+
+ const key = `${library}/${pageName}`;
+ const maybe = pageCache.get(key);
+ if (maybe) {return maybe;}
+ const filePath = `${DEFAULT_CDN_BASE}/${key}.md`;
+ const stub: PageInfo = {key, name: pageName, description: undefined, filePath, sections: []};
+ pageCache.set(stub.key, stub);
+ return stub;
+}
diff --git a/packages/dev/mcp/shared/src/parser.ts b/packages/dev/mcp/shared/src/parser.ts
new file mode 100644
index 00000000000..35c38d27c52
--- /dev/null
+++ b/packages/dev/mcp/shared/src/parser.ts
@@ -0,0 +1,52 @@
+import type {SectionInfo} from './types.js';
+
+export function parseSectionsFromMarkdown(lines: string[]): SectionInfo[] {
+ const sections: SectionInfo[] = [];
+ let inCode = false;
+ for (let idx = 0; idx < lines.length; idx++) {
+ const line = lines[idx];
+ if (/^```/.test(line.trim())) {inCode = !inCode;}
+ if (inCode) {continue;}
+ if (line.startsWith('## ')) {
+ const name = line.replace(/^##\s+/, '').trim();
+ sections.push({name, startLine: idx, endLine: lines.length});
+ }
+ }
+ for (let s = 0; s < sections.length - 1; s++) {
+ sections[s].endLine = sections[s + 1].startLine;
+ }
+ return sections;
+}
+
+export function extractNameAndDescription(lines: string[]): {name: string, description?: string} {
+ let name = '';
+ let description: string | undefined = undefined;
+
+ let i = 0;
+ for (; i < lines.length; i++) {
+ const line = lines[i];
+ if (line.startsWith('# ')) {
+ name = line.replace(/^#\s+/, '').trim();
+ i++;
+ break;
+ }
+ }
+
+ let descLines: string[] = [];
+ let inCode = false;
+ for (; i < lines.length; i++) {
+ const line = lines[i];
+ if (/^```/.test(line.trim())) {inCode = !inCode;}
+ if (inCode) {continue;}
+ if (line.trim() === '') {
+ if (descLines.length > 0) {break;} else {continue;}
+ }
+ if (/^#{1,6}\s/.test(line) || /^ 0) {
+ description = descLines.join('\n').trim();
+ }
+
+ return {name, description};
+}
diff --git a/packages/dev/mcp/shared/src/server.ts b/packages/dev/mcp/shared/src/server.ts
new file mode 100644
index 00000000000..be545f7d314
--- /dev/null
+++ b/packages/dev/mcp/shared/src/server.ts
@@ -0,0 +1,102 @@
+import {buildPageIndex, ensureParsedPage, resolvePageRef} from './page-manager.js';
+import {errorToString, fetchText} from './utils.js';
+import type {Library} from './types.js';
+import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
+import {parseSectionsFromMarkdown} from './parser.js';
+import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
+import {z} from 'zod';
+
+export async function startServer(
+ library: Library,
+ version: string,
+ registerAdditionalTools?: (server: McpServer) => void | Promise
+) {
+ const server = new McpServer({
+ name: library === 's2' ? 's2-docs-server' : 'react-aria-docs-server',
+ version
+ });
+
+ // Build page index at startup.
+ try {
+ await buildPageIndex(library);
+ } catch (e) {
+ console.warn(`Warning: failed to load ${library} docs index (${errorToString(e)}).`);
+ }
+
+ const toolPrefix = library === 's2' ? 's2' : 'react_aria';
+
+ server.registerTool(
+ `list_${toolPrefix}_pages`,
+ {
+ title: library === 's2' ? 'List React Spectrum (@react-spectrum/s2) docs pages' : 'List React Aria docs pages',
+ description: `Returns a list of available pages in the ${library} docs.`,
+ inputSchema: {includeDescription: z.boolean().optional()}
+ },
+ async ({includeDescription}) => {
+ const pages = await buildPageIndex(library);
+ const items = pages
+ .sort((a, b) => a.key.localeCompare(b.key))
+ .map(p => includeDescription ? {name: p.name, description: p.description ?? ''} : {name: p.name});
+ return {
+ content: [{type: 'text', text: JSON.stringify(items, null, 2)}]
+ };
+ }
+ );
+
+ server.registerTool(
+ `get_${toolPrefix}_page_info`,
+ {
+ title: 'Get page info',
+ description: 'Returns page description and list of sections for a given page.',
+ inputSchema: {page_name: z.string()}
+ },
+ async ({page_name}) => {
+ const ref = await resolvePageRef(library, page_name);
+ const info = await ensureParsedPage(ref);
+ const out = {
+ name: info.name,
+ description: info.description ?? '',
+ sections: info.sections.map(s => s.name)
+ };
+ return {content: [{type: 'text', text: JSON.stringify(out, null, 2)}]};
+ }
+ );
+
+ server.registerTool(
+ `get_${toolPrefix}_page`,
+ {
+ title: 'Get page markdown',
+ description: 'Returns the full markdown content for a page, or a specific section if provided.',
+ inputSchema: {page_name: z.string(), section_name: z.string().optional()}
+ },
+ async ({page_name, section_name}) => {
+ const ref = await resolvePageRef(library, page_name);
+ let text: string;
+ text = await fetchText(ref.filePath);
+
+ if (!section_name) {
+ return {content: [{type: 'text', text}]} as const;
+ }
+
+ const lines = text.split(/\r?\n/);
+ const sections = parseSectionsFromMarkdown(lines);
+ let section = sections.find(s => s.name === section_name);
+ if (!section) {
+ section = sections.find(s => s.name.toLowerCase() === section_name.toLowerCase());
+ }
+ if (!section) {
+ const available = sections.map(s => s.name).join(', ');
+ throw new Error(`Section '${section_name}' not found in ${ref.key}. Available: ${available}`);
+ }
+ const snippet = lines.slice(section.startLine, section.endLine).join('\n');
+ return {content: [{type: 'text', text: snippet}]} as const;
+ }
+ );
+
+ if (registerAdditionalTools) {
+ await registerAdditionalTools(server);
+ }
+
+ const transport = new StdioServerTransport();
+ await server.connect(transport);
+}
diff --git a/packages/dev/mcp/shared/src/types.ts b/packages/dev/mcp/shared/src/types.ts
new file mode 100644
index 00000000000..66351e3c753
--- /dev/null
+++ b/packages/dev/mcp/shared/src/types.ts
@@ -0,0 +1,15 @@
+export type SectionInfo = {
+ name: string,
+ startLine: number, // 0-based index where section heading starts
+ endLine: number // exclusive end line index for section content
+};
+
+export type PageInfo = {
+ key: string, // e.g. "s2/Button"
+ name: string, // from top-level heading
+ description?: string, // first paragraph after name
+ filePath: string, // absolute path to markdown file
+ sections: SectionInfo[]
+};
+
+export type Library = 's2' | 'react-aria';
diff --git a/packages/dev/mcp/shared/src/utils.ts b/packages/dev/mcp/shared/src/utils.ts
new file mode 100644
index 00000000000..ba6a62f039a
--- /dev/null
+++ b/packages/dev/mcp/shared/src/utils.ts
@@ -0,0 +1,30 @@
+export function errorToString(err: unknown): string {
+ if (err && typeof err === 'object' && 'stack' in err && typeof (err as any).stack === 'string') {
+ return (err as any).stack as string;
+ }
+ if (err && typeof err === 'object' && 'message' in err && typeof (err as any).message === 'string') {
+ return (err as any).message as string;
+ }
+ try {
+ return JSON.stringify(err);
+ } catch {
+ return String(err);
+ }
+}
+
+// CDN base for docs. Can be overridden via env variable.
+export const DEFAULT_CDN_BASE = process.env.DOCS_CDN_BASE ?? 'https://react-spectrum.adobe.com/beta';
+
+export async function fetchText(url: string, timeoutMs = 15000): Promise {
+ const ctrl = new AbortController();
+ const id = setTimeout(() => ctrl.abort(), timeoutMs).unref?.();
+ try {
+ const res = await fetch(url, {signal: ctrl.signal, cache: 'no-store'} as any);
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status} for ${url}`);
+ }
+ return await res.text();
+ } finally {
+ clearTimeout(id as any);
+ }
+}
diff --git a/packages/dev/mcp/shared/tsconfig.json b/packages/dev/mcp/shared/tsconfig.json
new file mode 100644
index 00000000000..f89590aa3ea
--- /dev/null
+++ b/packages/dev/mcp/shared/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "noEmit": false,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "es2020",
+ "types": ["node"],
+ "declaration": false,
+ "sourceMap": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["src/**/*"],
+ "exclude": [
+ "dist",
+ "node_modules",
+ "s2/dist",
+ "react-aria/dist"
+ ]
+}
diff --git a/packages/dev/mcp/src/index.ts b/packages/dev/mcp/src/index.ts
deleted file mode 100644
index 5ad5221fa01..00000000000
--- a/packages/dev/mcp/src/index.ts
+++ /dev/null
@@ -1,423 +0,0 @@
-#!/usr/bin/env node
-///
-import {fileURLToPath} from 'url';
-import fs from 'fs';
-import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
-import path from 'path';
-import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
-import {z} from 'zod';
-
-type SectionInfo = {
- name: string,
- startLine: number, // 0-based index where section heading starts
- endLine: number // exclusive end line index for section content
-};
-
-type PageInfo = {
- key: string, // e.g. "s2/Button"
- name: string, // from top-level heading
- description?: string, // first paragraph after name
- filePath: string, // absolute path to markdown file
- sections: SectionInfo[]
-};
-
-type Library = 's2' | 'react-aria';
-
-function errorToString(err: unknown): string {
- if (err && typeof err === 'object' && 'stack' in err && typeof (err as any).stack === 'string') {
- return (err as any).stack as string;
- }
- if (err && typeof err === 'object' && 'message' in err && typeof (err as any).message === 'string') {
- return (err as any).message as string;
- }
- try {
- return JSON.stringify(err);
- } catch {
- return String(err);
- }
-}
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-// CDN base for docs. Can be overridden via env variable.
-const DEFAULT_CDN_BASE = process.env.DOCS_CDN_BASE
- ?? 'https://reactspectrum.blob.core.windows.net/reactspectrum/a22a0aed3e97d0a23b9883679798b85eed68413d/s2-docs';
-
-function libBaseUrl(library: Library) {
- return `${DEFAULT_CDN_BASE}/${library}`;
-}
-
-async function fetchText(url: string, timeoutMs = 15000): Promise {
- const ctrl = new AbortController();
- const id = setTimeout(() => ctrl.abort(), timeoutMs).unref?.();
- try {
- const res = await fetch(url, {signal: ctrl.signal, cache: 'no-store'} as any);
- if (!res.ok) {
- throw new Error(`HTTP ${res.status} for ${url}`);
- }
- return await res.text();
- } finally {
- clearTimeout(id as any);
- }
-}
-
-// Cache of parsed pages
-const pageCache = new Map();
-
-let iconIdCache: string[] | null = null;
-let illustrationIdCache: string[] | null = null;
-let iconAliasesCache: Record | null = null;
-let illustrationAliasesCache: Record | null = null;
-
-function readBundledJson(filename: string): any | null {
- try {
- const p = path.resolve(__dirname, 'data', filename); // dist/data
- if (!fs.existsSync(p)) {return null;}
- const txt = fs.readFileSync(p, 'utf8');
- return JSON.parse(txt);
- } catch {
- return null;
- }
-}
-
-function listIconNames(): string[] {
- if (iconIdCache) {return iconIdCache;}
- const bundled = readBundledJson('icons.json');
- return (iconIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
-}
-
-function listIllustrationNames(): string[] {
- if (illustrationIdCache) {return illustrationIdCache;}
- const bundled = readBundledJson('illustrations.json');
- return (illustrationIdCache = Array.isArray(bundled) ? bundled.slice().sort((a, b) => a.localeCompare(b)) : []);
-}
-
-async function loadIconAliases(): Promise> {
- if (iconAliasesCache) {return iconAliasesCache;}
- const bundled = readBundledJson('iconAliases.json');
- return (iconAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
-}
-
-async function loadIllustrationAliases(): Promise> {
- if (illustrationAliasesCache) {return illustrationAliasesCache;}
- const bundled = readBundledJson('illustrationAliases.json');
- return (illustrationAliasesCache = (bundled && typeof bundled === 'object') ? bundled : {});
-}
-
-// Whether we've loaded the page index for a library yet.
-const pageIndexLoaded = new Set();
-
-// Build a lightweight index of pages for the given library from the CDN's llms.txt.
-// Populates pageCache with stubs (title from filename; description/sections omitted).
-async function buildPageIndex(library: Library): Promise {
- if (pageIndexLoaded.has(library)) {
- return Array.from(pageCache.values()).filter(p => p.key.startsWith(`${library}/`));
- }
-
- const pages: PageInfo[] = [];
-
- // Read llms.txt to enumerate available pages without downloading them all.
- const llmsUrl = `${libBaseUrl(library)}/llms.txt`;
- const txt = await fetchText(llmsUrl);
- const re = /^\s*-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*:\s*(.*))?\s*$/;
- for (const line of txt.split(/\r?\n/)) {
- const m = line.match(re);
- if (!m) {continue;}
- const display = (m[1] || '').trim();
- const href = (m[2] || '').trim();
- const description = (m[3] || '').trim() || undefined;
- if (!href || !/\.md$/i.test(href)) {continue;}
- const key = href.replace(/\.md$/i, '').replace(/\\/g, '/');
- const name = display || path.basename(key);
- const filePath = `${DEFAULT_CDN_BASE}/${key}.md`;
- const info: PageInfo = {key, name, description, filePath, sections: []};
- pages.push(info);
- pageCache.set(info.key, info);
- }
-
- pageIndexLoaded.add(library);
- return pages.sort((a, b) => a.key.localeCompare(b.key));
-}
-
-function parseSectionsFromMarkdown(lines: string[]): SectionInfo[] {
- const sections: SectionInfo[] = [];
- let inCode = false;
- for (let idx = 0; idx < lines.length; idx++) {
- const line = lines[idx];
- if (/^```/.test(line.trim())) {inCode = !inCode;}
- if (inCode) {continue;}
- if (line.startsWith('## ')) {
- const name = line.replace(/^##\s+/, '').trim();
- sections.push({name, startLine: idx, endLine: lines.length});
- }
- }
- for (let s = 0; s < sections.length - 1; s++) {
- sections[s].endLine = sections[s + 1].startLine;
- }
- return sections;
-}
-
-function extractNameAndDescription(lines: string[]): {name: string, description?: string} {
- let name = '';
- let description: string | undefined = undefined;
-
- let i = 0;
- for (; i < lines.length; i++) {
- const line = lines[i];
- if (line.startsWith('# ')) {
- name = line.replace(/^#\s+/, '').trim();
- i++;
- break;
- }
- }
-
- let descLines: string[] = [];
- let inCode = false;
- for (; i < lines.length; i++) {
- const line = lines[i];
- if (/^```/.test(line.trim())) {inCode = !inCode;}
- if (inCode) {continue;}
- if (line.trim() === '') {
- if (descLines.length > 0) {break;} else {continue;}
- }
- if (/^#{1,6}\s/.test(line) || /^ 0) {
- description = descLines.join('\n').trim();
- }
-
- return {name, description};
-}
-
-async function ensureParsedPage(info: PageInfo): Promise {
- if (info.sections && info.sections.length > 0 && info.description !== undefined) {
- return info;
- }
-
- const text = await fetchText(info.filePath);
- const lines = text.split(/\r?\n/);
- const {name, description} = extractNameAndDescription(lines);
- const sections = parseSectionsFromMarkdown(lines);
- const updated = {...info, name: name || info.name, description, sections};
- pageCache.set(updated.key, updated);
- return updated;
-}
-
-async function resolvePageRef(library: Library, pageName: string): Promise {
- // Ensure index is loaded
- await buildPageIndex(library);
-
- if (pageCache.has(pageName)) {
- return pageCache.get(pageName)!;
- }
-
- if (pageName.includes('/')) {
- const normalized = pageName.replace(/\\/g, '/');
- const prefix = normalized.split('/', 1)[0];
- if (prefix !== library) {
- throw new Error(`Page '${pageName}' is not in the '${library}' library.`);
- }
- const maybe = pageCache.get(normalized);
- if (maybe) {return maybe;}
- const filePath = `${DEFAULT_CDN_BASE}/${normalized}.md`;
- const stub: PageInfo = {key: normalized, name: path.basename(normalized), description: undefined, filePath, sections: []};
- pageCache.set(stub.key, stub);
- return stub;
- }
-
- const key = `${library}/${pageName}`;
- const maybe = pageCache.get(key);
- if (maybe) {return maybe;}
- const filePath = `${DEFAULT_CDN_BASE}/${key}.md`;
- const stub: PageInfo = {key, name: pageName, description: undefined, filePath, sections: []};
- pageCache.set(stub.key, stub);
- return stub;
-}
-
-async function startServer(library: Library) {
- const server = new McpServer({
- name: library === 's2' ? 's2-docs-server' : 'react-aria-docs-server',
- version: '0.1.0'
- });
-
- // Build page index at startup.
- try {
- await buildPageIndex(library);
- } catch (e) {
- console.warn(`Warning: failed to load ${library} docs index (${errorToString(e)}).`);
- }
-
- // list_pages tool
- const toolPrefix = library === 's2' ? 's2' : 'react_aria';
- server.registerTool(
- `list_${toolPrefix}_pages`,
- {
- title: library === 's2' ? 'List React Spectrum (@react-spectrum/s2) docs pages' : 'List React Aria docs pages',
- description: `Returns a list of available pages in the ${library} docs.`,
- inputSchema: {includeDescription: z.boolean().optional()}
- },
- async ({includeDescription}) => {
- const pages = await buildPageIndex(library);
- const items = pages
- .sort((a, b) => a.key.localeCompare(b.key))
- .map(p => includeDescription ? {name: p.name, description: p.description ?? ''} : {name: p.name});
- return {
- content: [{type: 'text', text: JSON.stringify(items, null, 2)}]
- };
- }
- );
-
- // get_page_info tool
- server.registerTool(
- `get_${toolPrefix}_page_info`,
- {
- title: 'Get page info',
- description: 'Returns page description and list of sections for a given page.',
- inputSchema: {page_name: z.string()}
- },
- async ({page_name}) => {
- const ref = await resolvePageRef(library, page_name);
- const info = await ensureParsedPage(ref);
- const out = {
- name: info.name,
- description: info.description ?? '',
- sections: info.sections.map(s => s.name)
- };
- return {content: [{type: 'text', text: JSON.stringify(out, null, 2)}]};
- }
- );
-
- // get_page tool
- server.registerTool(
- `get_${toolPrefix}_page`,
- {
- title: 'Get page markdown',
- description: 'Returns the full markdown content for a page, or a specific section if provided.',
- inputSchema: {page_name: z.string(), section_name: z.string().optional()}
- },
- async ({page_name, section_name}) => {
- const ref = await resolvePageRef(library, page_name);
- let text: string;
- text = await fetchText(ref.filePath);
-
- if (!section_name) {
- return {content: [{type: 'text', text}]} as const;
- }
-
- const lines = text.split(/\r?\n/);
- const sections = parseSectionsFromMarkdown(lines);
- let section = sections.find(s => s.name === section_name);
- if (!section) {
- section = sections.find(s => s.name.toLowerCase() === section_name.toLowerCase());
- }
- if (!section) {
- const available = sections.map(s => s.name).join(', ');
- throw new Error(`Section '${section_name}' not found in ${ref.key}. Available: ${available}`);
- }
- const snippet = lines.slice(section.startLine, section.endLine).join('\n');
- return {content: [{type: 'text', text: snippet}]} as const;
- }
- );
-
- if (library === 's2') {
- // search_icons tool
- server.registerTool(
- 'search_s2_icons',
- {
- title: 'Search S2 icons',
- description: 'Searches the S2 workflow icon set by one or more terms; returns matching icon names.',
- inputSchema: {terms: z.union([z.string(), z.array(z.string())])}
- },
- async ({terms}) => {
- const allNames = listIconNames();
- const nameSet = new Set(allNames);
- const aliases = await loadIconAliases();
- const rawTerms = Array.isArray(terms) ? terms : [terms];
- const normalized = Array.from(new Set(rawTerms.map(t => String(t ?? '').trim().toLowerCase()).filter(Boolean)));
- if (normalized.length === 0) {
- throw new Error('Provide at least one non-empty search term.');
- }
- // direct name matches
- const results = new Set(allNames.filter(name => {
- const nameLower = name.toLowerCase();
- return normalized.some(term => nameLower.includes(term));
- }));
- // alias matches
- for (const [aliasKey, targets] of Object.entries(aliases)) {
- if (!targets || targets.length === 0) {continue;}
- const aliasLower = aliasKey.toLowerCase();
- if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
- for (const t of targets) {
- const n = String(t);
- if (nameSet.has(n)) {results.add(n);}
- }
- }
- }
- return {content: [{type: 'text', text: JSON.stringify(Array.from(results).sort((a, b) => a.localeCompare(b)), null, 2)}]};
- }
- );
-
- // search_illustrations tool
- server.registerTool(
- 'search_s2_illustrations',
- {
- title: 'Search S2 illustrations',
- description: 'Searches the S2 illustrations set by one or more terms; returns matching illustration names.',
- inputSchema: {terms: z.union([z.string(), z.array(z.string())])}
- },
- async ({terms}) => {
- const allNames = listIllustrationNames();
- const nameSet = new Set(allNames);
- const aliases = await loadIllustrationAliases();
- const rawTerms = Array.isArray(terms) ? terms : [terms];
- const normalized = Array.from(new Set(rawTerms.map(t => String(t ?? '').trim().toLowerCase()).filter(Boolean)));
- if (normalized.length === 0) {
- throw new Error('Provide at least one non-empty search term.');
- }
- // direct name matches
- const results = new Set(allNames.filter(name => {
- const nameLower = name.toLowerCase();
- return normalized.some(term => nameLower.includes(term));
- }));
- // alias matches
- for (const [aliasKey, targets] of Object.entries(aliases)) {
- if (!targets || targets.length === 0) {continue;}
- const aliasLower = aliasKey.toLowerCase();
- if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
- for (const t of targets) {
- const n = String(t);
- if (nameSet.has(n)) {results.add(n);}
- }
- }
- }
- return {content: [{type: 'text', text: JSON.stringify(Array.from(results).sort((a, b) => a.localeCompare(b)), null, 2)}]};
- }
- );
- }
-
- const transport = new StdioServerTransport();
- await server.connect(transport);
-}
-
-function printUsage() {
- const usage = 'Usage: mcp \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';
- console.log(usage);
-}
-
-// CLI entry
-(async () => {
- try {
- const arg = (process.argv[2] || '').trim();
- if (arg === '--help' || arg === '-h' || arg === 'help') {
- printUsage();
- process.exit(0);
- }
- const library: Library = arg === 'react-aria' ? 'react-aria' : 's2';
- await startServer(library);
- } catch (err) {
- console.error(errorToString(err));
- process.exit(1);
- }
-})();
diff --git a/packages/dev/mcp/tsconfig.json b/packages/dev/mcp/tsconfig.json
deleted file mode 100644
index fb0111899fa..00000000000
--- a/packages/dev/mcp/tsconfig.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "extends": "../../..//tsconfig.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "noEmit": false,
- "module": "esnext",
- "moduleResolution": "bundler",
- "target": "es2018",
- "types": [],
- "declaration": false,
- "sourceMap": true
- },
- "include": ["src/**/*"]
-}
-
-
diff --git a/packages/dev/s2-docs/pages/react-aria/mcp.mdx b/packages/dev/s2-docs/pages/react-aria/mcp.mdx
index 6c12e9e1575..919f19735bb 100644
--- a/packages/dev/s2-docs/pages/react-aria/mcp.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/mcp.mdx
@@ -10,18 +10,18 @@ export const tags = ['mcp', 'ai', 'documentation', 'tools'];
# MCP Server
-The `@react-spectrum/mcp` package allows you to run [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) servers for React Aria locally. It exposes a set of tools that MCP clients can discover and call to browse the docs.
+The `@react-aria/mcp` package allows you to run a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for React Aria locally. It exposes a set of tools that MCP clients can discover and call to browse the docs.
## Using with an MCP client
-Add one or both servers to your MCP client configuration (the exact file and schema may depend on your client).
+Add the server to your MCP client configuration (the exact file and schema may depend on your client).
```js
{
"mcpServers": {
"React Aria": {
"command": "npx",
- "args": ["@react-spectrum/mcp", "react-aria"]
+ "args": ["@react-aria/mcp"]
}
}
}
@@ -29,7 +29,7 @@ Add one or both servers to your MCP client configuration (the exact file and sch
### Cursor
-
+
@@ -37,24 +37,23 @@ Add one or both servers to your MCP client configuration (the exact file and sch
-
Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
### VS Code
-
+
-Or follow VS Code's 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:
+Or follow VS Code's 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 the server using the VS Code CLI:
-
+
### Claude Code
-Use the Claude Code CLI to add the servers:
+Use the Claude Code CLI to add the server:
-
+
For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
@@ -65,16 +64,16 @@ Create or edit the configuration file `~/.codex/config.toml` and add:
```js
[mcp_servers.react-aria]
command = "npx"
-args = ["@react-spectrum/mcp", "react-aria"]
+args = ["@react-aria/mcp"]
```
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
### Gemini CLI
-Use the Gemini CLI to add the servers:
+Use the Gemini CLI to add the server:
-
+
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).
diff --git a/packages/dev/s2-docs/pages/s2/mcp.mdx b/packages/dev/s2-docs/pages/s2/mcp.mdx
index 0b7a6b2f77b..50ebed171e4 100644
--- a/packages/dev/s2-docs/pages/s2/mcp.mdx
+++ b/packages/dev/s2-docs/pages/s2/mcp.mdx
@@ -10,22 +10,18 @@ export const tags = ['mcp', 'ai', 'documentation', 'tools'];
# MCP Server
-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.
+The `@react-spectrum/mcp` package allows you to run a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for React Spectrum (S2) locally. It exposes a set of tools that MCP clients can discover and call to browse the docs, search for icons and illustrations, and more.
## Using with an MCP client
-Add one or both servers to your MCP client configuration (the exact file and schema may depend on your client).
+Add the server to your MCP client configuration (the exact file and schema may depend on your client).
```js
{
"mcpServers": {
"React Spectrum (S2)": {
"command": "npx",
- "args": ["@react-spectrum/mcp", "s2"]
- },
- "React Aria": {
- "command": "npx",
- "args": ["@react-spectrum/mcp", "react-aria"]
+ "args": ["@react-spectrum/mcp"]
}
}
}
@@ -33,9 +29,7 @@ Add one or both servers to your MCP client configuration (the exact file and sch
### Cursor
-React Spectrum (S2):
-
-
+
@@ -43,46 +37,23 @@ React Spectrum (S2):
-React Aria:
-
-
-
-
-
-
-
-
-
-
Or follow Cursor's MCP install [guide](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) and use the standard config above.
### VS Code
-React Spectrum (S2):
-
-
-
-
-
-React Aria:
-
-
+
-Or follow VS Code's 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:
+Or follow VS Code's 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 the server using the VS Code CLI:
-
-
-
+
### Claude Code
-Use the Claude Code CLI to add the servers:
-
-
+Use the Claude Code CLI to add the server:
-
+
For more information, see the [Claude Code MCP documentation](https://docs.claude.com/en/docs/claude-code/mcp).
@@ -93,22 +64,16 @@ Create or edit the configuration file `~/.codex/config.toml` and add:
```js
[mcp_servers.react-spectrum-s2]
command = "npx"
-args = ["@react-spectrum/mcp", "s2"]
-
-[mcp_servers.react-aria]
-command = "npx"
-args = ["@react-spectrum/mcp", "react-aria"]
+args = ["@react-spectrum/mcp"]
```
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers).
### Gemini CLI
-Use the Gemini CLI to add the servers:
-
-
+Use the Gemini CLI to add the server:
-
+
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).
diff --git a/yarn.lock b/yarn.lock
index 971f3098b1c..9c4d23849a1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5895,6 +5895,19 @@ __metadata:
languageName: unknown
linkType: soft
+"@react-aria/mcp@workspace:packages/dev/mcp/react-aria":
+ version: 0.0.0-use.local
+ resolution: "@react-aria/mcp@workspace:packages/dev/mcp/react-aria"
+ dependencies:
+ "@modelcontextprotocol/sdk": "npm:^1.17.3"
+ "@swc/helpers": "npm:^0.5.0"
+ typescript: "npm:^5.8.2"
+ zod: "npm:^3.23.8"
+ bin:
+ mcp: dist/react-aria/src/index.js
+ languageName: unknown
+ linkType: soft
+
"@react-aria/menu@npm:^3.19.3, @react-aria/menu@workspace:packages/@react-aria/menu":
version: 0.0.0-use.local
resolution: "@react-aria/menu@workspace:packages/@react-aria/menu"
@@ -7224,9 +7237,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@react-spectrum/mcp@workspace:packages/dev/mcp":
+"@react-spectrum/mcp@workspace:packages/dev/mcp/s2":
version: 0.0.0-use.local
- resolution: "@react-spectrum/mcp@workspace:packages/dev/mcp"
+ resolution: "@react-spectrum/mcp@workspace:packages/dev/mcp/s2"
dependencies:
"@modelcontextprotocol/sdk": "npm:^1.17.3"
"@swc/helpers": "npm:^0.5.0"
@@ -7234,7 +7247,7 @@ __metadata:
typescript: "npm:^5.8.2"
zod: "npm:^3.23.8"
bin:
- mcp: dist/index.js
+ mcp: dist/s2/src/index.js
languageName: unknown
linkType: soft