From 1fa0e82dfa22ba605badf77fcec00c62397313ee Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Oct 2025 15:09:30 -0500 Subject: [PATCH 1/6] refactor mcp server to be two separate servers --- eslint.config.mjs | 3 +- package.json | 6 +- packages/dev/mcp/README.md | 172 ------- packages/dev/mcp/package.json | 23 +- packages/dev/mcp/react-aria/README.md | 110 +++++ packages/dev/mcp/react-aria/package.json | 41 ++ packages/dev/mcp/react-aria/src/index.ts | 19 + packages/dev/mcp/react-aria/tsconfig.json | 17 + packages/dev/mcp/s2/README.md | 112 +++++ packages/dev/mcp/s2/package.json | 44 ++ packages/dev/mcp/s2/src/index.ts | 95 ++++ packages/dev/mcp/s2/src/s2-data.ts | 47 ++ packages/dev/mcp/s2/tsconfig.json | 17 + packages/dev/mcp/scripts/build-data.mjs | 3 +- packages/dev/mcp/src/common/page-manager.ts | 90 ++++ packages/dev/mcp/src/common/parser.ts | 52 +++ packages/dev/mcp/src/common/server.ts | 102 +++++ packages/dev/mcp/src/common/types.ts | 15 + packages/dev/mcp/src/common/utils.ts | 30 ++ packages/dev/mcp/src/index.ts | 423 ------------------ packages/dev/mcp/tsconfig.json | 12 +- packages/dev/s2-docs/pages/react-aria/mcp.mdx | 33 +- packages/dev/s2-docs/pages/s2/mcp.mdx | 69 +-- yarn.lock | 31 +- 24 files changed, 881 insertions(+), 685 deletions(-) delete mode 100644 packages/dev/mcp/README.md create mode 100644 packages/dev/mcp/react-aria/README.md create mode 100644 packages/dev/mcp/react-aria/package.json create mode 100644 packages/dev/mcp/react-aria/src/index.ts create mode 100644 packages/dev/mcp/react-aria/tsconfig.json create mode 100644 packages/dev/mcp/s2/README.md create mode 100644 packages/dev/mcp/s2/package.json create mode 100644 packages/dev/mcp/s2/src/index.ts create mode 100644 packages/dev/mcp/s2/src/s2-data.ts create mode 100644 packages/dev/mcp/s2/tsconfig.json create mode 100644 packages/dev/mcp/src/common/page-manager.ts create mode 100644 packages/dev/mcp/src/common/parser.ts create mode 100644 packages/dev/mcp/src/common/server.ts create mode 100644 packages/dev/mcp/src/common/types.ts create mode 100644 packages/dev/mcp/src/common/utils.ts delete mode 100644 packages/dev/mcp/src/index.ts 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): - -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=s2-docs&config=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3AgczIifQ%3D%3D) - -React Aria: - -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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): - -[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) - -React Aria: - -[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) - - -#### 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/package.json b/packages/dev/mcp/package.json index 2719984b267..e03bbb1cf1e 100644 --- a/packages/dev/mcp/package.json +++ b/packages/dev/mcp/package.json @@ -1,15 +1,9 @@ { - "name": "@react-spectrum/mcp", + "name": "mcp-packages", + "private": true, "version": "0.1.0", - "description": "MCP server for React Spectrum (S2) and React Aria documentation", + "description": "Shared code for MCP servers", "type": "module", - "bin": "dist/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" - }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.3", "@swc/helpers": "^0.5.0", @@ -19,13 +13,6 @@ "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" @@ -38,7 +25,5 @@ "dist", "src" ], - "sideEffects": [ - "*.css" - ] + "sideEffects": false } diff --git a/packages/dev/mcp/react-aria/README.md b/packages/dev/mcp/react-aria/README.md new file mode 100644 index 00000000000..82b035c8f5f --- /dev/null +++ b/packages/dev/mcp/react-aria/README.md @@ -0,0 +1,110 @@ +# @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: + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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: + +[Install in VS Code](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. | diff --git a/packages/dev/mcp/react-aria/package.json b/packages/dev/mcp/react-aria/package.json new file mode 100644 index 00000000000..83ffcb8afb6 --- /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/index.js", + "scripts": { + "prepublishOnly": "yarn build", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "dev": "node --enable-source-maps dist/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/react-aria/src/index.ts b/packages/dev/mcp/react-aria/src/index.ts new file mode 100644 index 00000000000..3db2e113357 --- /dev/null +++ b/packages/dev/mcp/react-aria/src/index.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/// +import {errorToString} from '../../src/common/utils.js'; +import {startServer} from '../../src/common/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..01ae5a177d4 --- /dev/null +++ b/packages/dev/mcp/react-aria/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "..", + "skipLibCheck": true, + "types": ["node"] + }, + "include": [ + "src/**/*", + "../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..ec0b81c8bb3 --- /dev/null +++ b/packages/dev/mcp/s2/README.md @@ -0,0 +1,112 @@ +# @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: + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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: + +[Install in VS Code](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. | diff --git a/packages/dev/mcp/s2/package.json b/packages/dev/mcp/s2/package.json new file mode 100644 index 00000000000..63dbd485bbf --- /dev/null +++ b/packages/dev/mcp/s2/package.json @@ -0,0 +1,44 @@ +{ + "name": "@react-spectrum/mcp", + "version": "0.1.0", + "description": "MCP server for React Spectrum (S2) documentation", + "type": "module", + "bin": "dist/s2/src/index.js", + "scripts": { + "prepublishOnly": "yarn build", + "build": "node ../scripts/build-data.mjs && tsc -p tsconfig.json", + "start": "node dist/s2/src/index.js", + "dev": "node --enable-source-maps dist/s2/src/index.js" + }, + "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" + }, + "engines": { + "node": ">=18" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "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/s2/src/index.ts b/packages/dev/mcp/s2/src/index.ts new file mode 100644 index 00000000000..fbff4658ce0 --- /dev/null +++ b/packages/dev/mcp/s2/src/index.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/// +import {errorToString} from '../../src/common/utils.js'; +import {listIconNames, listIllustrationNames, loadIconAliases, loadIllustrationAliases} from './s2-data.js'; +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {startServer} from '../../src/common/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..394b2bb2fe2 --- /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 common/ 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..01ae5a177d4 --- /dev/null +++ b/packages/dev/mcp/s2/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "..", + "skipLibCheck": true, + "types": ["node"] + }, + "include": [ + "src/**/*", + "../src/**/*" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/dev/mcp/scripts/build-data.mjs b/packages/dev/mcp/scripts/build-data.mjs index 170a4bc9e26..3d2c60b3905 100644 --- a/packages/dev/mcp/scripts/build-data.mjs +++ b/packages/dev/mcp/scripts/build-data.mjs @@ -7,7 +7,8 @@ import path from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, '../../../..'); -const OUT_DIR = path.resolve(__dirname, '../dist/data'); +// Output to s2/dist/data for @react-spectrum/mcp package +const OUT_DIR = path.resolve(__dirname, '../s2/dist/data'); const ICONS_DIR = path.resolve(REPO_ROOT, 'packages/@react-spectrum/s2/s2wf-icons'); const ILLUSTRATIONS_DIR = path.resolve(REPO_ROOT, 'packages/@react-spectrum/s2/spectrum-illustrations/linear'); diff --git a/packages/dev/mcp/src/common/page-manager.ts b/packages/dev/mcp/src/common/page-manager.ts new file mode 100644 index 00000000000..8c3553b9d73 --- /dev/null +++ b/packages/dev/mcp/src/common/page-manager.ts @@ -0,0 +1,90 @@ +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 { + // 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; +} diff --git a/packages/dev/mcp/src/common/parser.ts b/packages/dev/mcp/src/common/parser.ts new file mode 100644 index 00000000000..35c38d27c52 --- /dev/null +++ b/packages/dev/mcp/src/common/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/src/common/server.ts b/packages/dev/mcp/src/common/server.ts new file mode 100644 index 00000000000..be545f7d314 --- /dev/null +++ b/packages/dev/mcp/src/common/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/src/common/types.ts b/packages/dev/mcp/src/common/types.ts new file mode 100644 index 00000000000..66351e3c753 --- /dev/null +++ b/packages/dev/mcp/src/common/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/src/common/utils.ts b/packages/dev/mcp/src/common/utils.ts new file mode 100644 index 00000000000..ba6a62f039a --- /dev/null +++ b/packages/dev/mcp/src/common/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/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 index fb0111899fa..5c96dcac2d6 100644 --- a/packages/dev/mcp/tsconfig.json +++ b/packages/dev/mcp/tsconfig.json @@ -7,11 +7,15 @@ "module": "esnext", "moduleResolution": "bundler", "target": "es2018", - "types": [], + "types": ["node"], "declaration": false, "sourceMap": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "s2/src/s2-data.ts"], + "exclude": [ + "dist", + "node_modules", + "s2/dist", + "react-aria/dist" + ] } - - diff --git a/packages/dev/s2-docs/pages/react-aria/mcp.mdx b/packages/dev/s2-docs/pages/react-aria/mcp.mdx index 6c12e9e1575..d530b40f86e 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 - + Install in Visual Studio 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,19 +64,27 @@ 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). ### Windsurf Follow the 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. | diff --git a/packages/dev/s2-docs/pages/s2/mcp.mdx b/packages/dev/s2-docs/pages/s2/mcp.mdx index 0b7a6b2f77b..114c524dde1 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,19 +29,7 @@ Add one or both servers to your MCP client configuration (the exact file and sch ### Cursor -React Spectrum (S2): - - - - - - Add to Cursor - - - -React Aria: - - + @@ -53,36 +37,23 @@ 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): - - + Install in Visual Studio Code -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 the server using the VS Code CLI: - - Install in Visual Studio 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: - - - - + ### 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,25 +64,29 @@ 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). ### Windsurf Follow the 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. | diff --git a/yarn.lock b/yarn.lock index 971f3098b1c..38dd9a39bc7 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/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 @@ -20951,6 +20964,18 @@ __metadata: languageName: node linkType: hard +"mcp-packages@workspace:packages/dev/mcp": + version: 0.0.0-use.local + resolution: "mcp-packages@workspace:packages/dev/mcp" + dependencies: + "@modelcontextprotocol/sdk": "npm:^1.17.3" + "@swc/helpers": "npm:^0.5.0" + fast-glob: "npm:^3.3.3" + typescript: "npm:^5.8.2" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + "md5@npm:^2.2.1": version: 2.2.1 resolution: "md5@npm:2.2.1" From 18bd3a2132428c72cab91c4a5c6533131f82cf27 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Fri, 17 Oct 2025 16:21:47 -0500 Subject: [PATCH 2/6] update README and docs --- packages/dev/mcp/react-aria/README.md | 28 +++++++++++++++++++ packages/dev/mcp/s2/README.md | 28 +++++++++++++++++++ packages/dev/s2-docs/pages/react-aria/mcp.mdx | 10 +------ packages/dev/s2-docs/pages/s2/mcp.mdx | 12 +------- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/dev/mcp/react-aria/README.md b/packages/dev/mcp/react-aria/README.md index 82b035c8f5f..1769215c8db 100644 --- a/packages/dev/mcp/react-aria/README.md +++ b/packages/dev/mcp/react-aria/README.md @@ -108,3 +108,31 @@ Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/m | `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/s2/README.md b/packages/dev/mcp/s2/README.md index ec0b81c8bb3..ac9f203d7ed 100644 --- a/packages/dev/mcp/s2/README.md +++ b/packages/dev/mcp/s2/README.md @@ -110,3 +110,31 @@ Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/m | `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/s2-docs/pages/react-aria/mcp.mdx b/packages/dev/s2-docs/pages/react-aria/mcp.mdx index d530b40f86e..919f19735bb 100644 --- a/packages/dev/s2-docs/pages/react-aria/mcp.mdx +++ b/packages/dev/s2-docs/pages/react-aria/mcp.mdx @@ -29,7 +29,7 @@ Add the server to your MCP client configuration (the exact file and schema may d ### Cursor - + @@ -80,11 +80,3 @@ For more information, see the [Gemini CLI MCP documentation](https://github.com/ ### Windsurf Follow the 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. | diff --git a/packages/dev/s2-docs/pages/s2/mcp.mdx b/packages/dev/s2-docs/pages/s2/mcp.mdx index 114c524dde1..50ebed171e4 100644 --- a/packages/dev/s2-docs/pages/s2/mcp.mdx +++ b/packages/dev/s2-docs/pages/s2/mcp.mdx @@ -29,7 +29,7 @@ Add the server to your MCP client configuration (the exact file and schema may d ### Cursor - + @@ -80,13 +80,3 @@ For more information, see the [Gemini CLI MCP documentation](https://github.com/ ### Windsurf Follow the 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. | From f67a57dadfcfa99de87501a0b4fe6cf6547c2985 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Fri, 17 Oct 2025 16:42:40 -0500 Subject: [PATCH 3/6] fix data loading --- packages/dev/mcp/s2/src/s2-data.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dev/mcp/s2/src/s2-data.ts b/packages/dev/mcp/s2/src/s2-data.ts index 394b2bb2fe2..8b37337cf18 100644 --- a/packages/dev/mcp/s2/src/s2-data.ts +++ b/packages/dev/mcp/s2/src/s2-data.ts @@ -12,8 +12,8 @@ let illustrationAliasesCache: Record | null = null; function readBundledJson(filename: string): any | null { try { - // Go up from common/ to dist/, then to data/ - const p = path.resolve(__dirname, '..', 'data', filename); + // 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); From d8be5cd42c6280f26643ea4a169fa1383c43ab67 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Fri, 17 Oct 2025 16:44:35 -0500 Subject: [PATCH 4/6] cleanup --- packages/dev/mcp/scripts/build-data.mjs | 1 - packages/dev/mcp/src/common/page-manager.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/dev/mcp/scripts/build-data.mjs b/packages/dev/mcp/scripts/build-data.mjs index 3d2c60b3905..21bd617e484 100644 --- a/packages/dev/mcp/scripts/build-data.mjs +++ b/packages/dev/mcp/scripts/build-data.mjs @@ -7,7 +7,6 @@ import path from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, '../../../..'); -// Output to s2/dist/data for @react-spectrum/mcp package const OUT_DIR = path.resolve(__dirname, '../s2/dist/data'); const ICONS_DIR = path.resolve(REPO_ROOT, 'packages/@react-spectrum/s2/s2wf-icons'); diff --git a/packages/dev/mcp/src/common/page-manager.ts b/packages/dev/mcp/src/common/page-manager.ts index 8c3553b9d73..c89b09d2554 100644 --- a/packages/dev/mcp/src/common/page-manager.ts +++ b/packages/dev/mcp/src/common/page-manager.ts @@ -59,7 +59,6 @@ export async function ensureParsedPage(info: PageInfo): Promise { } export async function resolvePageRef(library: Library, pageName: string): Promise { - // Ensure index is loaded await buildPageIndex(library); if (pageCache.has(pageName)) { From 125206cc9c5152dc1372d9df9934b166c723f6bf Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 23 Oct 2025 14:47:21 -0500 Subject: [PATCH 5/6] improve structure --- packages/dev/mcp/react-aria/package.json | 6 ++-- .../scripts/smoke-list-pages.mjs | 0 packages/dev/mcp/react-aria/src/index.ts | 4 +-- packages/dev/mcp/react-aria/tsconfig.json | 12 +++++-- packages/dev/mcp/s2/package.json | 2 +- .../dev/mcp/{ => s2}/scripts/build-data.mjs | 34 ++++++++++--------- .../dev/mcp/s2/scripts/smoke-list-pages.mjs | 28 +++++++++++++++ packages/dev/mcp/s2/src/index.ts | 4 +-- packages/dev/mcp/s2/tsconfig.json | 12 +++++-- packages/dev/mcp/{ => shared}/package.json | 2 +- .../common => shared/src}/page-manager.ts | 0 .../mcp/{src/common => shared/src}/parser.ts | 0 .../mcp/{src/common => shared/src}/server.ts | 0 .../mcp/{src/common => shared/src}/types.ts | 0 .../mcp/{src/common => shared/src}/utils.ts | 0 packages/dev/mcp/{ => shared}/tsconfig.json | 10 +++--- yarn.lock | 12 ------- 17 files changed, 79 insertions(+), 47 deletions(-) rename packages/dev/mcp/{ => react-aria}/scripts/smoke-list-pages.mjs (100%) rename packages/dev/mcp/{ => s2}/scripts/build-data.mjs (72%) create mode 100644 packages/dev/mcp/s2/scripts/smoke-list-pages.mjs rename packages/dev/mcp/{ => shared}/package.json (95%) rename packages/dev/mcp/{src/common => shared/src}/page-manager.ts (100%) rename packages/dev/mcp/{src/common => shared/src}/parser.ts (100%) rename packages/dev/mcp/{src/common => shared/src}/server.ts (100%) rename packages/dev/mcp/{src/common => shared/src}/types.ts (100%) rename packages/dev/mcp/{src/common => shared/src}/utils.ts (100%) rename packages/dev/mcp/{ => shared}/tsconfig.json (61%) diff --git a/packages/dev/mcp/react-aria/package.json b/packages/dev/mcp/react-aria/package.json index 83ffcb8afb6..0c6872a17c3 100644 --- a/packages/dev/mcp/react-aria/package.json +++ b/packages/dev/mcp/react-aria/package.json @@ -3,12 +3,12 @@ "version": "0.1.0", "description": "MCP server for React Aria documentation", "type": "module", - "bin": "dist/index.js", + "bin": "dist/react-aria/src/index.js", "scripts": { "prepublishOnly": "yarn build", "build": "tsc -p tsconfig.json", - "start": "node dist/index.js", - "dev": "node --enable-source-maps dist/index.js" + "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", 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 index 3db2e113357..170113e1388 100644 --- a/packages/dev/mcp/react-aria/src/index.ts +++ b/packages/dev/mcp/react-aria/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /// -import {errorToString} from '../../src/common/utils.js'; -import {startServer} from '../../src/common/server.js'; +import {errorToString} from '../../shared/src/utils.js'; +import {startServer} from '../../shared/src/server.js'; // CLI entry for React Aria (async () => { diff --git a/packages/dev/mcp/react-aria/tsconfig.json b/packages/dev/mcp/react-aria/tsconfig.json index 01ae5a177d4..3f0a13a0930 100644 --- a/packages/dev/mcp/react-aria/tsconfig.json +++ b/packages/dev/mcp/react-aria/tsconfig.json @@ -1,14 +1,20 @@ { - "extends": "../tsconfig.json", + "extends": "../../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", "rootDir": "..", "skipLibCheck": true, - "types": ["node"] + "types": ["node"], + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2020", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": false }, "include": [ "src/**/*", - "../src/**/*" + "../shared/src/**/*" ], "exclude": [ "dist", diff --git a/packages/dev/mcp/s2/package.json b/packages/dev/mcp/s2/package.json index 63dbd485bbf..07e833e8dde 100644 --- a/packages/dev/mcp/s2/package.json +++ b/packages/dev/mcp/s2/package.json @@ -6,7 +6,7 @@ "bin": "dist/s2/src/index.js", "scripts": { "prepublishOnly": "yarn build", - "build": "node ../scripts/build-data.mjs && tsc -p tsconfig.json", + "build": "node ./scripts/build-data.mjs && tsc -p tsconfig.json", "start": "node dist/s2/src/index.js", "dev": "node --enable-source-maps dist/s2/src/index.js" }, diff --git a/packages/dev/mcp/scripts/build-data.mjs b/packages/dev/mcp/s2/scripts/build-data.mjs similarity index 72% rename from packages/dev/mcp/scripts/build-data.mjs rename to packages/dev/mcp/s2/scripts/build-data.mjs index 21bd617e484..8145ee77aa6 100644 --- a/packages/dev/mcp/scripts/build-data.mjs +++ b/packages/dev/mcp/s2/scripts/build-data.mjs @@ -6,8 +6,8 @@ import path from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '../../../..'); -const OUT_DIR = path.resolve(__dirname, '../s2/dist/data'); +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'); const ILLUSTRATIONS_DIR = path.resolve(REPO_ROOT, 'packages/@react-spectrum/s2/spectrum-illustrations/linear'); @@ -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 index fbff4658ce0..1e6e76fa161 100644 --- a/packages/dev/mcp/s2/src/index.ts +++ b/packages/dev/mcp/s2/src/index.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node /// -import {errorToString} from '../../src/common/utils.js'; +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 '../../src/common/server.js'; +import {startServer} from '../../shared/src/server.js'; import {z} from 'zod'; // CLI entry for S2 diff --git a/packages/dev/mcp/s2/tsconfig.json b/packages/dev/mcp/s2/tsconfig.json index 01ae5a177d4..3f0a13a0930 100644 --- a/packages/dev/mcp/s2/tsconfig.json +++ b/packages/dev/mcp/s2/tsconfig.json @@ -1,14 +1,20 @@ { - "extends": "../tsconfig.json", + "extends": "../../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", "rootDir": "..", "skipLibCheck": true, - "types": ["node"] + "types": ["node"], + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2020", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": false }, "include": [ "src/**/*", - "../src/**/*" + "../shared/src/**/*" ], "exclude": [ "dist", diff --git a/packages/dev/mcp/package.json b/packages/dev/mcp/shared/package.json similarity index 95% rename from packages/dev/mcp/package.json rename to packages/dev/mcp/shared/package.json index e03bbb1cf1e..efdf5891e28 100644 --- a/packages/dev/mcp/package.json +++ b/packages/dev/mcp/shared/package.json @@ -1,5 +1,5 @@ { - "name": "mcp-packages", + "name": "mcp-shared", "private": true, "version": "0.1.0", "description": "Shared code for MCP servers", diff --git a/packages/dev/mcp/src/common/page-manager.ts b/packages/dev/mcp/shared/src/page-manager.ts similarity index 100% rename from packages/dev/mcp/src/common/page-manager.ts rename to packages/dev/mcp/shared/src/page-manager.ts diff --git a/packages/dev/mcp/src/common/parser.ts b/packages/dev/mcp/shared/src/parser.ts similarity index 100% rename from packages/dev/mcp/src/common/parser.ts rename to packages/dev/mcp/shared/src/parser.ts diff --git a/packages/dev/mcp/src/common/server.ts b/packages/dev/mcp/shared/src/server.ts similarity index 100% rename from packages/dev/mcp/src/common/server.ts rename to packages/dev/mcp/shared/src/server.ts diff --git a/packages/dev/mcp/src/common/types.ts b/packages/dev/mcp/shared/src/types.ts similarity index 100% rename from packages/dev/mcp/src/common/types.ts rename to packages/dev/mcp/shared/src/types.ts diff --git a/packages/dev/mcp/src/common/utils.ts b/packages/dev/mcp/shared/src/utils.ts similarity index 100% rename from packages/dev/mcp/src/common/utils.ts rename to packages/dev/mcp/shared/src/utils.ts diff --git a/packages/dev/mcp/tsconfig.json b/packages/dev/mcp/shared/tsconfig.json similarity index 61% rename from packages/dev/mcp/tsconfig.json rename to packages/dev/mcp/shared/tsconfig.json index 5c96dcac2d6..f89590aa3ea 100644 --- a/packages/dev/mcp/tsconfig.json +++ b/packages/dev/mcp/shared/tsconfig.json @@ -1,17 +1,19 @@ { - "extends": "../../..//tsconfig.json", + "extends": "../../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "noEmit": false, "module": "esnext", "moduleResolution": "bundler", - "target": "es2018", + "target": "es2020", "types": ["node"], "declaration": false, - "sourceMap": true + "sourceMap": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true }, - "include": ["src/**/*", "s2/src/s2-data.ts"], + "include": ["src/**/*"], "exclude": [ "dist", "node_modules", diff --git a/yarn.lock b/yarn.lock index 38dd9a39bc7..18bd2351781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20964,18 +20964,6 @@ __metadata: languageName: node linkType: hard -"mcp-packages@workspace:packages/dev/mcp": - version: 0.0.0-use.local - resolution: "mcp-packages@workspace:packages/dev/mcp" - dependencies: - "@modelcontextprotocol/sdk": "npm:^1.17.3" - "@swc/helpers": "npm:^0.5.0" - fast-glob: "npm:^3.3.3" - typescript: "npm:^5.8.2" - zod: "npm:^3.23.8" - languageName: unknown - linkType: soft - "md5@npm:^2.2.1": version: 2.2.1 resolution: "md5@npm:2.2.1" From accba2212bebfb1fc97621a30c8c7a055840b049 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 23 Oct 2025 14:49:05 -0500 Subject: [PATCH 6/6] yarn.lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 18bd2351781..9c4d23849a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5904,7 +5904,7 @@ __metadata: typescript: "npm:^5.8.2" zod: "npm:^3.23.8" bin: - mcp: dist/index.js + mcp: dist/react-aria/src/index.js languageName: unknown linkType: soft