Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/dev/mcp/s2/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down
305 changes: 154 additions & 151 deletions packages/dev/mcp/s2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
26 changes: 18 additions & 8 deletions packages/dev/mcp/shared/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,30 @@ import {z} from 'zod';
export async function startServer(
library: Library,
version: string,
registerAdditionalTools?: (server: McpServer) => void | Promise<void>
options: {
additionalLibraries?: Library[];
registerAdditionalTools?: (server: McpServer) => void | Promise<void>;
} = {}
) {
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);
Expand Down Expand Up @@ -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);
}
Loading