diff --git a/packages/dev/mcp/s2/README.md b/packages/dev/mcp/s2/README.md index b2a8b6d0075..e3d85f920d4 100644 --- a/packages/dev/mcp/s2/README.md +++ b/packages/dev/mcp/s2/README.md @@ -1,6 +1,6 @@ # @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. +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. It also bundles the React Aria docs tools, so you can browse React Aria documentation from the same server. ## Installation @@ -110,6 +110,10 @@ 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. | +| `get_style_macro_property_values` | `{ propertyName: string }` | Return allowed values for an S2 style macro property. | +| `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. | ## Privacy Policy diff --git a/packages/dev/mcp/s2/src/index.ts b/packages/dev/mcp/s2/src/index.ts index 9ba81be9d3f..6b60c62dd6d 100644 --- a/packages/dev/mcp/s2/src/index.ts +++ b/packages/dev/mcp/s2/src/index.ts @@ -23,173 +23,176 @@ import {z} from 'zod'; process.exit(0); } - await startServer('s2', readPackageVersion(import.meta.url), (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())])}, - annotations: {readOnlyHint: true, openWorldHint: true} - }, - 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; + await startServer('s2', readPackageVersion(import.meta.url), { + additionalLibraries: ['react-aria'], + registerAdditionalTools: (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())])}, + annotations: {readOnlyHint: true, openWorldHint: true} + }, + 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.'); } - 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); + // 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 + ) + } + ] + }; } - 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())])}, - annotations: {readOnlyHint: true, openWorldHint: true} - }, - 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; + 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())])}, + annotations: {readOnlyHint: true, openWorldHint: true} + }, + 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.'); } - 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); + // 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 + ) + } + ] + }; } - return { - content: [ - { - type: 'text', - text: JSON.stringify( - Array.from(results).sort((a, b) => a.localeCompare(b)), - null, - 2 - ) - } - ] - }; - } - ); + ); - server.registerTool( - 'get_style_macro_property_values', - { - title: 'Get style macro property values', - description: - 'Returns the allowed values for a given S2 style macro property (including expanded color/spacing value lists where applicable).', - inputSchema: {propertyName: z.string()}, - annotations: {readOnlyHint: true, openWorldHint: true} - }, - async ({propertyName}) => { - const name = String(propertyName ?? '').trim(); - if (!name) { - throw new Error('Provide a non-empty propertyName.'); - } + server.registerTool( + 'get_style_macro_property_values', + { + title: 'Get style macro property values', + description: + 'Returns the allowed values for a given S2 style macro property (including expanded color/spacing value lists where applicable).', + inputSchema: {propertyName: z.string()}, + annotations: {readOnlyHint: true, openWorldHint: true} + }, + async ({propertyName}) => { + const name = String(propertyName ?? '').trim(); + if (!name) { + throw new Error('Provide a non-empty propertyName.'); + } - const all = loadStyleMacroPropertyValues(); - let def = all[name]; - if (!def) { - // fallback to case-insensitive lookup - const lower = name.toLowerCase(); - const matchKey = Object.keys(all).find(k => k.toLowerCase() === lower); - if (matchKey) { - def = all[matchKey]; + const all = loadStyleMacroPropertyValues(); + let def = all[name]; + if (!def) { + // fallback to case-insensitive lookup + const lower = name.toLowerCase(); + const matchKey = Object.keys(all).find(k => k.toLowerCase() === lower); + if (matchKey) { + def = all[matchKey]; + } } - } - if (!def) { - const available = Object.keys(all).sort((a, b) => a.localeCompare(b)); - throw new Error( - `Unknown style macro property '${name}'. Available properties: ${available.join(', ')}` - ); - } + if (!def) { + const available = Object.keys(all).sort((a, b) => a.localeCompare(b)); + throw new Error( + `Unknown style macro property '${name}'. Available properties: ${available.join(', ')}` + ); + } - return {content: [{type: 'text', text: JSON.stringify(def, null, 2)}]}; - } - ); + return {content: [{type: 'text', text: JSON.stringify(def, null, 2)}]}; + } + ); + } }); } catch (err) { console.error(errorToString(err)); diff --git a/packages/dev/mcp/shared/src/server.ts b/packages/dev/mcp/shared/src/server.ts index 7d1231df3de..b724913ac85 100644 --- a/packages/dev/mcp/shared/src/server.ts +++ b/packages/dev/mcp/shared/src/server.ts @@ -9,13 +9,30 @@ import {z} from 'zod'; export async function startServer( library: Library, version: string, - registerAdditionalTools?: (server: McpServer) => void | Promise + options: { + additionalLibraries?: Library[]; + registerAdditionalTools?: (server: McpServer) => void | Promise; + } = {} ) { const server = new McpServer({ name: library === 's2' ? 's2-docs-server' : 'react-aria-docs-server', version }); + const libraries: Library[] = [library, ...(options.additionalLibraries ?? [])]; + for (const lib of libraries) { + await registerLibraryDocsTools(server, lib); + } + + if (options.registerAdditionalTools) { + await options.registerAdditionalTools(server); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +async function registerLibraryDocsTools(server: McpServer, library: Library) { // Build page index at startup. try { await buildPageIndex(library); @@ -103,11 +120,4 @@ export async function startServer( 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/s2-docs/scripts/generateMcpb.mjs b/packages/dev/s2-docs/scripts/generateMcpb.mjs index d8bf4d9375c..54cbafd87a3 100644 --- a/packages/dev/s2-docs/scripts/generateMcpb.mjs +++ b/packages/dev/s2-docs/scripts/generateMcpb.mjs @@ -34,7 +34,7 @@ const libraries = { extensionName: 'react-spectrum-s2', description: "Build apps with Adobe's React Spectrum component library.", longDescription: - 'Provides tools for browsing the React Spectrum (S2) documentation, including listing and reading pages, searching for available icons and illustrations, and looking up available styling token values. Uses the React Spectrum documentation content available at https://react-spectrum.adobe.com.', + 'Provides tools for browsing the React Spectrum (S2) documentation, including listing and reading pages, searching for available icons and illustrations, and looking up available styling token values. Also bundles the React Aria docs tools for browsing React Aria documentation from the same server. Uses the React Spectrum documentation content available at https://react-spectrum.adobe.com and React Aria documentation content available at https://react-aria.adobe.com.', homepage: 'https://react-spectrum.adobe.com/', documentation: 'https://react-spectrum.adobe.com/ai.html', iconSvg: path.join(assetsDir, 'rsp-favicon.svg'), @@ -55,7 +55,8 @@ const libraries = { name: 'get_style_macro_property_values', description: 'Returns the allowed values for a given S2 style macro property (including expanded color/spacing value lists where applicable).' - } + }, + ...sharedPageTools('React Aria', 'react_aria') ], srcDirs: [ {