From ddff6b578dac3e3ff87b8d27cb9b355d4c5630dc Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 13 Nov 2025 13:08:19 -0800 Subject: [PATCH 1/3] docs: add supported locales to provider docs (#9182) --- packages/dev/s2-docs/pages/s2/Provider.mdx | 48 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/Provider.mdx b/packages/dev/s2-docs/pages/s2/Provider.mdx index f4717f572d5..ecf2a093b6e 100644 --- a/packages/dev/s2-docs/pages/s2/Provider.mdx +++ b/packages/dev/s2-docs/pages/s2/Provider.mdx @@ -2,7 +2,7 @@ import {Layout} from '../../src/Layout'; import {InstallCommand} from '../../src/InstallCommand'; import {BundlerSwitcher, BundlerSwitcherItem} from '../../src/BundlerSwitcher'; export default Layout; -import {SegmentedControl, SegmentedControlItem} from '@react-spectrum/s2'; +import {Disclosure, DisclosureTitle, DisclosurePanel} from '@react-spectrum/s2'; export const section = 'Components'; import docs from 'docs:@react-spectrum/s2'; @@ -28,7 +28,7 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; ``` ## Locales -By default, React Spectrum chooses the locale matching the user’s browser/operating system language, but this can be overridden with the locale prop if you have an application specific setting. This prop accepts a [BCP 47](https://www.ietf.org/rfc/bcp/bcp47.txt) language code. A list of supported locales is available [here](Concepts.html#supported-locales) +By default, React Spectrum chooses the locale matching the user’s browser/operating system language, but this can be overridden with the locale prop if you have an application specific setting. This prop accepts a [BCP 47](https://www.ietf.org/rfc/bcp/bcp47.txt) language code. ```tsx @@ -36,6 +36,50 @@ By default, React Spectrum chooses the locale matching the user’s browser/oper ``` + + Supported Locales + + + + + + ## Client side routing The Provider component accepts an optional `router` prop. This enables React Spectrum components that render links to perform client side navigation using your application or framework's client side router. See the client side routing guide for details on how to set this up. From 66cbd6348cb2306578a9d2a102d456e3c40ef6b5 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 13 Nov 2025 18:25:56 -0600 Subject: [PATCH 2/3] docs: more S2 docs fixes (#9183) * fix client listeners Element assumption * improve Illustrations search (copy feedback + message) * dynamic search placeholder if resources tag selected * silently handle prefetch failures * fix focus visible style so it doesnt linger on click * fix anchor links * don't clear search field when switching library tabs * fix anchor links on page load * better cards for releases --------- Co-authored-by: Daniel Lu --- packages/dev/s2-docs/src/ComponentCard.tsx | 17 ++++- .../dev/s2-docs/src/IllustrationCards.tsx | 70 ++++++++++++++----- packages/dev/s2-docs/src/SearchMenu.tsx | 11 +-- packages/dev/s2-docs/src/client.tsx | 62 ++++++++++++++-- packages/dev/s2-docs/src/prefetch.ts | 3 + 5 files changed, 132 insertions(+), 31 deletions(-) diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx index 407bd5eb7dc..91f6afaa63c 100644 --- a/packages/dev/s2-docs/src/ComponentCard.tsx +++ b/packages/dev/s2-docs/src/ComponentCard.tsx @@ -266,6 +266,14 @@ function getDefaultIllustration(href: string) { return AdobeDefaultSvg; } +function getReleaseVersionLabel(href: string) { + let match = href.match(/releases\/(v[\w-]+)\.html$/i); + if (!match) { + return null; + } + return match[1].replace(/-/g, '.'); +} + interface ComponentCardProps extends Omit { name: string, href: string, @@ -276,7 +284,14 @@ export function ComponentCard({id, name, href, description, size, ...otherProps} let IllustrationComponent = componentIllustrations[name] || getDefaultIllustration(href); let overrides = propOverrides[name] || {}; let preview; - if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) { + let releaseVersion = getReleaseVersionLabel(href); + if (releaseVersion) { + preview = ( +
+ {releaseVersion} +
+ ); + } else if (href.includes('react-aria/examples/') && !href.endsWith('index.html')) { preview = ; } else { preview = ( diff --git a/packages/dev/s2-docs/src/IllustrationCards.tsx b/packages/dev/s2-docs/src/IllustrationCards.tsx index be0a82112a9..f475fbd058b 100644 --- a/packages/dev/s2-docs/src/IllustrationCards.tsx +++ b/packages/dev/s2-docs/src/IllustrationCards.tsx @@ -1,15 +1,18 @@ 'use client'; import {Autocomplete, GridLayout, ListBox, ListBoxItem, Size, useFilter, Virtualizer} from 'react-aria-components'; +// eslint-disable-next-line monorepo/no-internal-import +import Checkmark from '@react-spectrum/s2/illustrations/gradient/generic1/Checkmark'; import {Content, Heading, IllustratedMessage, pressScale, ProgressCircle, Radio, RadioGroup, SearchField, SegmentedControl, SegmentedControlItem, Text, UNSTABLE_ToastQueue as ToastQueue} from '@react-spectrum/s2'; -import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {focusRing, iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; // @ts-ignore import Gradient from '@react-spectrum/s2/icons/Gradient'; import {illustrationAliases} from './illustrationAliases.js'; +import InfoCircle from '@react-spectrum/s2/icons/InfoCircle'; // eslint-disable-next-line monorepo/no-internal-import import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults'; import Polygon4 from '@react-spectrum/s2/icons/Polygon4'; -import React, {Suspense, use, useCallback, useRef, useState} from 'react'; +import React, {Suspense, use, useCallback, useEffect, useRef, useState} from 'react'; type IllustrationItemType = { id: string, @@ -22,7 +25,7 @@ const itemStyle = style({ backgroundColor: { default: 'gray-50', isHovered: 'gray-100', - isFocused: 'gray-100', + isFocusVisible: 'gray-100', isSelected: 'neutral' }, font: 'ui-sm', @@ -77,6 +80,7 @@ export function IllustrationCards() { Generic 2 )} + }> @@ -93,19 +97,48 @@ function Loading() { ); } -let handleCopyImport = (id: string, variant: string, gradientStyle: string) => { - let importText = variant === 'gradient' ? - `import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` : - `import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`; - navigator.clipboard.writeText(importText).then(() => { - // noop - }).catch(() => { - ToastQueue.negative('Failed to copy import statement.'); - }); -}; +function CopyInfoMessage() { + return ( +
+ + Press an item to copy its import statement +
+ ); +} + +function useCopyImport(variant: string, gradientStyle: string) { + let [copiedId, setCopiedId] = useState(null); + let timeout = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, []); + + let handleCopyImport = useCallback((id: string) => { + if (timeout.current) { + clearTimeout(timeout.current); + } + let importText = variant === 'gradient' ? + `import ${id} from '@react-spectrum/s2/illustrations/gradient/${gradientStyle}/${id}';` : + `import ${id} from '@react-spectrum/s2/illustrations/linear/${id}';`; + navigator.clipboard.writeText(importText).then(() => { + setCopiedId(id); + timeout.current = setTimeout(() => setCopiedId(null), 2000); + }).catch(() => { + ToastQueue.negative('Failed to copy import statement.'); + }); + }, [variant, gradientStyle]); + + return {copiedId, handleCopyImport}; +} function IllustrationList({variant, gradientStyle}) { let items = use(loadIllustrations(variant, gradientStyle)); + let {copiedId, handleCopyImport} = useCopyImport(variant, gradientStyle); return ( handleCopyImport(item.toString(), variant, gradientStyle)} + onAction={(item) => handleCopyImport(item.toString())} + dependencies={[copiedId]} className={style({height: 560, width: '100%', maxHeight: '100%', overflow: 'auto', scrollPaddingY: 4})} renderEmptyState={() => ( @@ -132,25 +166,25 @@ function IllustrationList({variant, gradientStyle}) { )}> - {(item: IllustrationItemType) => } + {(item: IllustrationItemType) => } ); } -function IllustrationItem({item}: {item: IllustrationItemType}) { +function IllustrationItem({item, isCopied = false}: {item: IllustrationItemType, isCopied?: boolean}) { let Illustration = item.Component; let ref = useRef(null); return ( - + {isCopied ? : }
- {item.id} + {isCopied ? 'Copied!' : item.id}
); diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index e1674bceb79..46d1ba3d306 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -186,9 +186,6 @@ export function SearchMenu(props: SearchMenuProps) { ); const handleTabSelectionChange = React.useCallback((key: Key) => { - if (searchValue) { - setSearchValue(''); - } setSelectedLibrary(key as typeof selectedLibrary); // Focus main search field of the newly selected tab setTimeout(() => { @@ -198,7 +195,7 @@ export function SearchMenu(props: SearchMenuProps) { searchRef.current.focus(); } }, 10); - }, [searchValue]); + }, []); const handleSectionSelectionChange = React.useCallback((keys: Iterable) => { const firstKey = Array.from(keys)[0] as string; @@ -275,6 +272,10 @@ export function SearchMenu(props: SearchMenuProps) { {orderedTabs.map((tab, i) => { const tabResourceTags = getResourceTags(tab.id); + const selectedResourceTag = tabResourceTags.find(tag => tag.id === selectedTagId); + const placeholderText = selectedResourceTag + ? `Search ${selectedResourceTag.name}` + : `Search ${tab.label}`; return ( @@ -286,7 +287,7 @@ export function SearchMenu(props: SearchMenuProps) { ref={searchRef} size="L" aria-label={`Search ${tab.label}`} - placeholder={`Search ${tab.label}`} + placeholder={placeholderText} UNSAFE_style={{marginInlineEnd: 296, viewTransitionName: i === 0 ? 'search-menu-search-field' : 'none'} as CSSProperties} styles={style({width: 500})} /> diff --git a/packages/dev/s2-docs/src/client.tsx b/packages/dev/s2-docs/src/client.tsx index 0fe33508021..d58afb25a64 100644 --- a/packages/dev/s2-docs/src/client.tsx +++ b/packages/dev/s2-docs/src/client.tsx @@ -14,7 +14,7 @@ let isClientLink = (link: HTMLAnchorElement, pathname: string) => { link.href && (!link.target || link.target === '_self') && link.origin === location.origin && - link.pathname !== location.pathname && + (link.pathname !== location.pathname || link.hash) && !link.hasAttribute('download') && link.pathname.startsWith(pathname) ); @@ -36,7 +36,23 @@ let currentAbortController: AbortController | null = null; // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. async function navigate(pathname: string, push = false) { - let [basePath] = pathname.split('#'); + let [basePath, pathAnchor] = pathname.split('#'); + let currentPath = location.pathname; + let isSamePageAnchor = (!basePath || basePath === currentPath) && pathAnchor; + + if (isSamePageAnchor) { + if (push) { + history.pushState(null, '', pathname); + } + + // Scroll to the anchor + let element = document.getElementById(pathAnchor); + if (element) { + element.scrollIntoView(); + } + return; + } + let rscPath = basePath.replace('.html', '.rsc'); // Cancel any in-flight navigation @@ -154,7 +170,7 @@ function clearPrefetchTimeout() { } document.addEventListener('pointerover', e => { - let link = (e.target as Element).closest('a'); + let link = e.target instanceof Element ? e.target.closest('a') : null; let publicUrl = process.env.PUBLIC_URL || '/'; let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; @@ -172,14 +188,14 @@ document.addEventListener('pointerover', e => { // Clear prefetch timeout when pointer leaves a link document.addEventListener('pointerout', e => { - let link = (e.target as Element).closest('a'); + let link = e.target instanceof Element ? e.target.closest('a') : null; if (link && link === currentPrefetchLink) { clearPrefetchTimeout(); } }, true); document.addEventListener('focus', e => { - let link = (e.target as Element).closest('a'); + let link = e.target instanceof Element ? e.target.closest('a') : null; let publicUrl = process.env.PUBLIC_URL || '/'; let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; @@ -197,7 +213,7 @@ document.addEventListener('focus', e => { // Clear prefetch timeout when focus leaves a link document.addEventListener('blur', e => { - let link = (e.target as Element).closest('a'); + let link = e.target instanceof Element ? e.target.closest('a') : null; if (link && link === currentPrefetchLink) { clearPrefetchTimeout(); } @@ -205,7 +221,7 @@ document.addEventListener('blur', e => { // Intercept link clicks to perform RSC navigation. document.addEventListener('click', e => { - let link = (e.target as Element).closest('a'); + let link = e.target instanceof Element ? e.target.closest('a') : null; let publicUrl = process.env.PUBLIC_URL || '/'; let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; if ( @@ -227,3 +243,35 @@ document.addEventListener('click', e => { window.addEventListener('popstate', () => { navigate(location.pathname + location.search + location.hash); }); + +function scrollToCurrentHash() { + if (!location.hash || location.hash === '#') { + return; + } + + let anchorId = location.hash.slice(1); + try { + anchorId = decodeURIComponent(anchorId); + } catch { + // Fall back to raw hash + } + + if (!anchorId) { + return; + } + + requestAnimationFrame(() => { + let element = document.getElementById(anchorId); + if (element) { + element.scrollIntoView(); + } + }); +} + +if (document.readyState === 'complete' || document.readyState === 'interactive') { + scrollToCurrentHash(); +} else { + window.addEventListener('DOMContentLoaded', () => { + scrollToCurrentHash(); + }, {once: true}); +} diff --git a/packages/dev/s2-docs/src/prefetch.ts b/packages/dev/s2-docs/src/prefetch.ts index fe3425e5e31..033e123c89f 100644 --- a/packages/dev/s2-docs/src/prefetch.ts +++ b/packages/dev/s2-docs/src/prefetch.ts @@ -25,6 +25,9 @@ export function prefetchRoute(pathname: string) { prefetchPromises.delete(rscPath); return Promise.reject(new Error('Prefetch failed')); }); + + // Silently handle prefetch failures + prefetchPromise.catch(() => {}); prefetchPromises.set(rscPath, prefetchPromise); } From 46eb0efb53b0c184ab3262b2576479d99b9f4ffc Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 13 Nov 2025 18:26:49 -0600 Subject: [PATCH 3/3] fix: refactor mcp servers into separate packages (#9061) * refactor mcp server to be two separate servers * update README and docs * fix data loading * cleanup * improve structure * yarn.lock * update npx commands to use @latest --- eslint.config.mjs | 3 +- package.json | 6 +- packages/dev/mcp/README.md | 172 ------- packages/dev/mcp/react-aria/README.md | 138 ++++++ packages/dev/mcp/react-aria/package.json | 41 ++ .../scripts/smoke-list-pages.mjs | 0 packages/dev/mcp/react-aria/src/index.ts | 19 + packages/dev/mcp/react-aria/tsconfig.json | 23 + packages/dev/mcp/s2/README.md | 140 ++++++ packages/dev/mcp/{ => s2}/package.json | 18 +- .../dev/mcp/{ => s2}/scripts/build-data.mjs | 32 +- .../dev/mcp/s2/scripts/smoke-list-pages.mjs | 28 ++ packages/dev/mcp/s2/src/index.ts | 95 ++++ packages/dev/mcp/s2/src/s2-data.ts | 47 ++ packages/dev/mcp/s2/tsconfig.json | 23 + packages/dev/mcp/shared/package.json | 29 ++ packages/dev/mcp/shared/src/page-manager.ts | 89 ++++ packages/dev/mcp/shared/src/parser.ts | 52 +++ packages/dev/mcp/shared/src/server.ts | 102 +++++ packages/dev/mcp/shared/src/types.ts | 15 + packages/dev/mcp/shared/src/utils.ts | 30 ++ packages/dev/mcp/shared/tsconfig.json | 23 + packages/dev/mcp/src/index.ts | 423 ------------------ packages/dev/mcp/tsconfig.json | 17 - packages/dev/s2-docs/pages/react-aria/mcp.mdx | 23 +- packages/dev/s2-docs/pages/s2/mcp.mdx | 57 +-- yarn.lock | 19 +- 27 files changed, 964 insertions(+), 700 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 rename packages/dev/mcp/{ => react-aria}/scripts/smoke-list-pages.mjs (100%) 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 rename packages/dev/mcp/{ => s2}/package.json (76%) rename packages/dev/mcp/{ => s2}/scripts/build-data.mjs (74%) create mode 100644 packages/dev/mcp/s2/scripts/smoke-list-pages.mjs 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/shared/package.json create mode 100644 packages/dev/mcp/shared/src/page-manager.ts create mode 100644 packages/dev/mcp/shared/src/parser.ts create mode 100644 packages/dev/mcp/shared/src/server.ts create mode 100644 packages/dev/mcp/shared/src/types.ts create mode 100644 packages/dev/mcp/shared/src/utils.ts create mode 100644 packages/dev/mcp/shared/tsconfig.json delete mode 100644 packages/dev/mcp/src/index.ts delete mode 100644 packages/dev/mcp/tsconfig.json diff --git a/eslint.config.mjs b/eslint.config.mjs index ce69f768024..26fc3bde7b9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -59,7 +59,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 d4b78ada269..9bedcbadf33 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/react-aria/README.md b/packages/dev/mcp/react-aria/README.md new file mode 100644 index 00000000000..491dfa9f50c --- /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@latest +``` + +### 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@latest"] + } + } +} +``` + +
+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=eyJjb21tYW5kIjoibnB4IEByZWFjdC1hcmlhL21jcEBsYXRlc3QifQ%3D%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%40latest%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@latest"]}' +``` + +
+ +
+Claude Code + +Use the Claude Code CLI to add the server: + +```bash +claude mcp add react-aria npx @react-aria/mcp@latest +``` +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@latest"] +``` + +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@latest +``` + +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..5853319e33c --- /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@latest\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..72d66aa6394 --- /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@latest +``` + +### 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@latest"] + } + } +} +``` + +
+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=eyJjb21tYW5kIjoibnB4IEByZWFjdC1zcGVjdHJ1bS9tY3BAbGF0ZXN0In0%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%20Spectrum%20(S2)%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40react-spectrum%2Fmcp%40latest%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@latest"]}' +``` + +
+ +
+Claude Code + +Use the Claude Code CLI to add the server: + +```bash +claude mcp add react-spectrum-s2 npx @react-spectrum/mcp@latest +``` +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@latest"] +``` + +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@latest +``` + +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..e71b5ad9c0c --- /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@latest\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 30d7bd8964b..37a25622f66 100644 --- a/packages/dev/s2-docs/pages/react-aria/mcp.mdx +++ b/packages/dev/s2-docs/pages/react-aria/mcp.mdx @@ -14,14 +14,14 @@ export const tags = ['mcp', 'ai', 'documentation', 'tools']; ## 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@latest"] } } } @@ -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,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@latest"] ``` 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 d0abd219077..95a26824f9e 100644 --- a/packages/dev/s2-docs/pages/s2/mcp.mdx +++ b/packages/dev/s2-docs/pages/s2/mcp.mdx @@ -14,18 +14,14 @@ export const tags = ['mcp', 'ai', 'documentation', 'tools']; ## 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@latest"] } } } @@ -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: - - - - - - Add to Cursor - - - - 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: - - + 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). @@ -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@latest"] ``` 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 6730fecdde7..d980818b879 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