Skip to content

Commit b1b25ec

Browse files
authored
chore: include React Aria MCP tools in the React Spectrum MCP server (#10077)
* include React Aria MCP tools in the React Spectrum MCP server * formatting
1 parent 092229c commit b1b25ec

4 files changed

Lines changed: 180 additions & 162 deletions

File tree

packages/dev/mcp/s2/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @react-spectrum/mcp
22

3-
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.
3+
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.
44

55
## Installation
66

@@ -110,6 +110,10 @@ Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/m
110110
| `get_s2_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
111111
| `search_s2_icons` | `{ terms: string \| string[] }` | Search S2 workflow icon names. |
112112
| `search_s2_illustrations` | `{ terms: string \| string[] }` | Search S2 illustration names. |
113+
| `get_style_macro_property_values` | `{ propertyName: string }` | Return allowed values for an S2 style macro property. |
114+
| `list_react_aria_pages` | `{ includeDescription?: boolean }` | List available pages in the React Aria docs. |
115+
| `get_react_aria_page_info` | `{ page_name: string }` | Return page description and list of section titles. |
116+
| `get_react_aria_page` | `{ page_name: string, section_name?: string }` | Return full page markdown, or only the specified section. |
113117

114118
## Privacy Policy
115119

packages/dev/mcp/s2/src/index.ts

Lines changed: 154 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -23,173 +23,176 @@ import {z} from 'zod';
2323
process.exit(0);
2424
}
2525

26-
await startServer('s2', readPackageVersion(import.meta.url), (server: McpServer) => {
27-
server.registerTool(
28-
'search_s2_icons',
29-
{
30-
title: 'Search S2 icons',
31-
description:
32-
'Searches the S2 workflow icon set by one or more terms; returns matching icon names.',
33-
inputSchema: {terms: z.union([z.string(), z.array(z.string())])},
34-
annotations: {readOnlyHint: true, openWorldHint: true}
35-
},
36-
async ({terms}) => {
37-
const allNames = listIconNames();
38-
const nameSet = new Set(allNames);
39-
const aliases = await loadIconAliases();
40-
const rawTerms = Array.isArray(terms) ? terms : [terms];
41-
const normalized = Array.from(
42-
new Set(
43-
rawTerms
44-
.map(t =>
45-
String(t ?? '')
46-
.trim()
47-
.toLowerCase()
48-
)
49-
.filter(Boolean)
50-
)
51-
);
52-
if (normalized.length === 0) {
53-
throw new Error('Provide at least one non-empty search term.');
54-
}
55-
// direct name matches
56-
const results = new Set(
57-
allNames.filter(name => {
58-
const nameLower = name.toLowerCase();
59-
return normalized.some(term => nameLower.includes(term));
60-
})
61-
);
62-
// alias matches
63-
for (const [aliasKey, targets] of Object.entries(aliases)) {
64-
if (!targets || targets.length === 0) {
65-
continue;
26+
await startServer('s2', readPackageVersion(import.meta.url), {
27+
additionalLibraries: ['react-aria'],
28+
registerAdditionalTools: (server: McpServer) => {
29+
server.registerTool(
30+
'search_s2_icons',
31+
{
32+
title: 'Search S2 icons',
33+
description:
34+
'Searches the S2 workflow icon set by one or more terms; returns matching icon names.',
35+
inputSchema: {terms: z.union([z.string(), z.array(z.string())])},
36+
annotations: {readOnlyHint: true, openWorldHint: true}
37+
},
38+
async ({terms}) => {
39+
const allNames = listIconNames();
40+
const nameSet = new Set(allNames);
41+
const aliases = await loadIconAliases();
42+
const rawTerms = Array.isArray(terms) ? terms : [terms];
43+
const normalized = Array.from(
44+
new Set(
45+
rawTerms
46+
.map(t =>
47+
String(t ?? '')
48+
.trim()
49+
.toLowerCase()
50+
)
51+
.filter(Boolean)
52+
)
53+
);
54+
if (normalized.length === 0) {
55+
throw new Error('Provide at least one non-empty search term.');
6656
}
67-
const aliasLower = aliasKey.toLowerCase();
68-
if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
69-
for (const t of targets) {
70-
const n = String(t);
71-
if (nameSet.has(n)) {
72-
results.add(n);
57+
// direct name matches
58+
const results = new Set(
59+
allNames.filter(name => {
60+
const nameLower = name.toLowerCase();
61+
return normalized.some(term => nameLower.includes(term));
62+
})
63+
);
64+
// alias matches
65+
for (const [aliasKey, targets] of Object.entries(aliases)) {
66+
if (!targets || targets.length === 0) {
67+
continue;
68+
}
69+
const aliasLower = aliasKey.toLowerCase();
70+
if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
71+
for (const t of targets) {
72+
const n = String(t);
73+
if (nameSet.has(n)) {
74+
results.add(n);
75+
}
7376
}
7477
}
7578
}
79+
return {
80+
content: [
81+
{
82+
type: 'text',
83+
text: JSON.stringify(
84+
Array.from(results).sort((a, b) => a.localeCompare(b)),
85+
null,
86+
2
87+
)
88+
}
89+
]
90+
};
7691
}
77-
return {
78-
content: [
79-
{
80-
type: 'text',
81-
text: JSON.stringify(
82-
Array.from(results).sort((a, b) => a.localeCompare(b)),
83-
null,
84-
2
85-
)
86-
}
87-
]
88-
};
89-
}
90-
);
92+
);
9193

92-
server.registerTool(
93-
'search_s2_illustrations',
94-
{
95-
title: 'Search S2 illustrations',
96-
description:
97-
'Searches the S2 illustrations set by one or more terms; returns matching illustration names.',
98-
inputSchema: {terms: z.union([z.string(), z.array(z.string())])},
99-
annotations: {readOnlyHint: true, openWorldHint: true}
100-
},
101-
async ({terms}) => {
102-
const allNames = listIllustrationNames();
103-
const nameSet = new Set(allNames);
104-
const aliases = await loadIllustrationAliases();
105-
const rawTerms = Array.isArray(terms) ? terms : [terms];
106-
const normalized = Array.from(
107-
new Set(
108-
rawTerms
109-
.map(t =>
110-
String(t ?? '')
111-
.trim()
112-
.toLowerCase()
113-
)
114-
.filter(Boolean)
115-
)
116-
);
117-
if (normalized.length === 0) {
118-
throw new Error('Provide at least one non-empty search term.');
119-
}
120-
// direct name matches
121-
const results = new Set(
122-
allNames.filter(name => {
123-
const nameLower = name.toLowerCase();
124-
return normalized.some(term => nameLower.includes(term));
125-
})
126-
);
127-
// alias matches
128-
for (const [aliasKey, targets] of Object.entries(aliases)) {
129-
if (!targets || targets.length === 0) {
130-
continue;
94+
server.registerTool(
95+
'search_s2_illustrations',
96+
{
97+
title: 'Search S2 illustrations',
98+
description:
99+
'Searches the S2 illustrations set by one or more terms; returns matching illustration names.',
100+
inputSchema: {terms: z.union([z.string(), z.array(z.string())])},
101+
annotations: {readOnlyHint: true, openWorldHint: true}
102+
},
103+
async ({terms}) => {
104+
const allNames = listIllustrationNames();
105+
const nameSet = new Set(allNames);
106+
const aliases = await loadIllustrationAliases();
107+
const rawTerms = Array.isArray(terms) ? terms : [terms];
108+
const normalized = Array.from(
109+
new Set(
110+
rawTerms
111+
.map(t =>
112+
String(t ?? '')
113+
.trim()
114+
.toLowerCase()
115+
)
116+
.filter(Boolean)
117+
)
118+
);
119+
if (normalized.length === 0) {
120+
throw new Error('Provide at least one non-empty search term.');
131121
}
132-
const aliasLower = aliasKey.toLowerCase();
133-
if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
134-
for (const t of targets) {
135-
const n = String(t);
136-
if (nameSet.has(n)) {
137-
results.add(n);
122+
// direct name matches
123+
const results = new Set(
124+
allNames.filter(name => {
125+
const nameLower = name.toLowerCase();
126+
return normalized.some(term => nameLower.includes(term));
127+
})
128+
);
129+
// alias matches
130+
for (const [aliasKey, targets] of Object.entries(aliases)) {
131+
if (!targets || targets.length === 0) {
132+
continue;
133+
}
134+
const aliasLower = aliasKey.toLowerCase();
135+
if (normalized.some(term => aliasLower.includes(term) || term.includes(aliasLower))) {
136+
for (const t of targets) {
137+
const n = String(t);
138+
if (nameSet.has(n)) {
139+
results.add(n);
140+
}
138141
}
139142
}
140143
}
144+
return {
145+
content: [
146+
{
147+
type: 'text',
148+
text: JSON.stringify(
149+
Array.from(results).sort((a, b) => a.localeCompare(b)),
150+
null,
151+
2
152+
)
153+
}
154+
]
155+
};
141156
}
142-
return {
143-
content: [
144-
{
145-
type: 'text',
146-
text: JSON.stringify(
147-
Array.from(results).sort((a, b) => a.localeCompare(b)),
148-
null,
149-
2
150-
)
151-
}
152-
]
153-
};
154-
}
155-
);
157+
);
156158

157-
server.registerTool(
158-
'get_style_macro_property_values',
159-
{
160-
title: 'Get style macro property values',
161-
description:
162-
'Returns the allowed values for a given S2 style macro property (including expanded color/spacing value lists where applicable).',
163-
inputSchema: {propertyName: z.string()},
164-
annotations: {readOnlyHint: true, openWorldHint: true}
165-
},
166-
async ({propertyName}) => {
167-
const name = String(propertyName ?? '').trim();
168-
if (!name) {
169-
throw new Error('Provide a non-empty propertyName.');
170-
}
159+
server.registerTool(
160+
'get_style_macro_property_values',
161+
{
162+
title: 'Get style macro property values',
163+
description:
164+
'Returns the allowed values for a given S2 style macro property (including expanded color/spacing value lists where applicable).',
165+
inputSchema: {propertyName: z.string()},
166+
annotations: {readOnlyHint: true, openWorldHint: true}
167+
},
168+
async ({propertyName}) => {
169+
const name = String(propertyName ?? '').trim();
170+
if (!name) {
171+
throw new Error('Provide a non-empty propertyName.');
172+
}
171173

172-
const all = loadStyleMacroPropertyValues();
173-
let def = all[name];
174-
if (!def) {
175-
// fallback to case-insensitive lookup
176-
const lower = name.toLowerCase();
177-
const matchKey = Object.keys(all).find(k => k.toLowerCase() === lower);
178-
if (matchKey) {
179-
def = all[matchKey];
174+
const all = loadStyleMacroPropertyValues();
175+
let def = all[name];
176+
if (!def) {
177+
// fallback to case-insensitive lookup
178+
const lower = name.toLowerCase();
179+
const matchKey = Object.keys(all).find(k => k.toLowerCase() === lower);
180+
if (matchKey) {
181+
def = all[matchKey];
182+
}
180183
}
181-
}
182184

183-
if (!def) {
184-
const available = Object.keys(all).sort((a, b) => a.localeCompare(b));
185-
throw new Error(
186-
`Unknown style macro property '${name}'. Available properties: ${available.join(', ')}`
187-
);
188-
}
185+
if (!def) {
186+
const available = Object.keys(all).sort((a, b) => a.localeCompare(b));
187+
throw new Error(
188+
`Unknown style macro property '${name}'. Available properties: ${available.join(', ')}`
189+
);
190+
}
189191

190-
return {content: [{type: 'text', text: JSON.stringify(def, null, 2)}]};
191-
}
192-
);
192+
return {content: [{type: 'text', text: JSON.stringify(def, null, 2)}]};
193+
}
194+
);
195+
}
193196
});
194197
} catch (err) {
195198
console.error(errorToString(err));

packages/dev/mcp/shared/src/server.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,30 @@ import {z} from 'zod';
99
export async function startServer(
1010
library: Library,
1111
version: string,
12-
registerAdditionalTools?: (server: McpServer) => void | Promise<void>
12+
options: {
13+
additionalLibraries?: Library[];
14+
registerAdditionalTools?: (server: McpServer) => void | Promise<void>;
15+
} = {}
1316
) {
1417
const server = new McpServer({
1518
name: library === 's2' ? 's2-docs-server' : 'react-aria-docs-server',
1619
version
1720
});
1821

22+
const libraries: Library[] = [library, ...(options.additionalLibraries ?? [])];
23+
for (const lib of libraries) {
24+
await registerLibraryDocsTools(server, lib);
25+
}
26+
27+
if (options.registerAdditionalTools) {
28+
await options.registerAdditionalTools(server);
29+
}
30+
31+
const transport = new StdioServerTransport();
32+
await server.connect(transport);
33+
}
34+
35+
async function registerLibraryDocsTools(server: McpServer, library: Library) {
1936
// Build page index at startup.
2037
try {
2138
await buildPageIndex(library);
@@ -103,11 +120,4 @@ export async function startServer(
103120
return {content: [{type: 'text', text: snippet}]} as const;
104121
}
105122
);
106-
107-
if (registerAdditionalTools) {
108-
await registerAdditionalTools(server);
109-
}
110-
111-
const transport = new StdioServerTransport();
112-
await server.connect(transport);
113123
}

0 commit comments

Comments
 (0)