@primer/mcp 0.5.1 → 1.0.0-rc.f27c5cca1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
- import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
1
+ import {inputRequired, inputResponse, McpServer} from '@modelcontextprotocol/server'
2
+
2
3
  // eslint-disable-next-line import/no-namespace
3
4
  import * as cheerio from 'cheerio'
4
5
  // eslint-disable-next-line import/no-namespace
@@ -22,54 +23,55 @@ import {
22
23
  } from './primitives'
23
24
  import packageJson from '../package.json' with {type: 'json'}
24
25
 
25
- const server = new McpServer({
26
- name: 'Primer',
27
- version: packageJson.version,
28
- })
29
-
30
26
  const turndownService = new TurndownService()
31
27
 
32
28
  // Load all tokens with guidelines from primitives
33
29
  const allTokensWithGuidelines: TokenWithGuidelines[] = loadAllTokensWithGuidelines()
34
30
 
35
- // -----------------------------------------------------------------------------
36
- // Project setup
37
- // -----------------------------------------------------------------------------
38
- server.registerTool(
39
- 'init',
40
- {
41
- description: 'Setup or create a project that includes Primer React',
42
- annotations: {readOnlyHint: true},
43
- },
44
- async () => {
45
- const url = new URL(`/product/getting-started/react`, 'https://primer.style')
46
- const response = await fetch(url)
47
- if (!response.ok) {
48
- throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
49
- }
50
-
51
- const html = await response.text()
52
- if (!html) {
53
- return {
54
- content: [],
31
+ function createServer() {
32
+ const server = new McpServer({
33
+ name: 'Primer',
34
+ version: packageJson.version,
35
+ })
36
+
37
+ // -----------------------------------------------------------------------------
38
+ // Project setup
39
+ // -----------------------------------------------------------------------------
40
+ server.registerTool(
41
+ 'init',
42
+ {
43
+ description: 'Setup or create a project that includes Primer React',
44
+ annotations: {readOnlyHint: true},
45
+ },
46
+ async () => {
47
+ const url = new URL(`/product/getting-started/react`, 'https://primer.style')
48
+ const response = await fetch(url)
49
+ if (!response.ok) {
50
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
55
51
  }
56
- }
57
52
 
58
- const $ = cheerio.load(html)
59
- const source = $('main').html()
60
- if (!source) {
61
- return {
62
- content: [],
53
+ const html = await response.text()
54
+ if (!html) {
55
+ return {
56
+ content: [],
57
+ }
63
58
  }
64
- }
65
59
 
66
- const text = turndownService.turndown(source)
60
+ const $ = cheerio.load(html)
61
+ const source = $('main').html()
62
+ if (!source) {
63
+ return {
64
+ content: [],
65
+ }
66
+ }
67
67
 
68
- return {
69
- content: [
70
- {
71
- type: 'text',
72
- text: `The getting started documentation for Primer React is included below. It's important that the project:
68
+ const text = turndownService.turndown(source)
69
+
70
+ return {
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: `The getting started documentation for Primer React is included below. It's important that the project:
73
75
 
74
76
  - Is using a tool like Vite, Next.js, etc that supports TypeScript and React. If the project does not have support for that, generate an appropriate project scaffold
75
77
  - Installs the latest version of \`@primer/react\` from \`npm\`
@@ -82,895 +84,895 @@ server.registerTool(
82
84
 
83
85
  ${text}
84
86
  `,
85
- },
86
- ],
87
- }
88
- },
89
- )
90
-
91
- // -----------------------------------------------------------------------------
92
- // Components
93
- // -----------------------------------------------------------------------------
94
- server.registerTool(
95
- 'list_components',
96
- {description: 'List all of the components available from Primer React', annotations: {readOnlyHint: true}},
97
- async () => {
98
- const components = listComponents().map(component => {
99
- return `- ${component.name}`
100
- })
101
- return {
102
- content: [
103
- {
104
- type: 'text',
105
- text: `The following components are available in the @primer/react in TypeScript projects:
87
+ },
88
+ ],
89
+ }
90
+ },
91
+ )
92
+
93
+ // -----------------------------------------------------------------------------
94
+ // Components
95
+ // -----------------------------------------------------------------------------
96
+ server.registerTool(
97
+ 'list_components',
98
+ {description: 'List all of the components available from Primer React', annotations: {readOnlyHint: true}},
99
+ async () => {
100
+ const components = listComponents().map(component => {
101
+ return `- ${component.name}`
102
+ })
103
+ return {
104
+ content: [
105
+ {
106
+ type: 'text',
107
+ text: `The following components are available in the @primer/react in TypeScript projects:
106
108
 
107
109
  ${components.join('\n')}
108
110
 
109
111
  Use \`get_component\` for exactly one component and \`get_component_batch\` for 2 to 10 components. You can use these components from the @primer/react package.`,
110
- },
111
- ],
112
- }
113
- },
114
- )
115
-
116
- server.registerTool(
117
- 'get_component',
118
- {
119
- description:
120
- 'Retrieve official documentation and usage details for exactly one React component from the @primer/react package. Use get_component_batch for 2 to 10 components.',
121
- inputSchema: {
122
- name: z.string().describe('The name of the component to retrieve'),
112
+ },
113
+ ],
114
+ }
123
115
  },
124
- annotations: {readOnlyHint: true},
125
- },
126
- async ({name}) => {
127
- const components = listComponents()
128
- const match = components.find(component => {
129
- return component.name === name || component.name.toLowerCase() === name.toLowerCase()
130
- })
131
- if (!match) {
116
+ )
117
+
118
+ server.registerTool(
119
+ 'get_component',
120
+ {
121
+ description:
122
+ 'Retrieve official documentation and usage details for exactly one React component from the @primer/react package. Use get_component_batch for 2 to 10 components.',
123
+ inputSchema: z.object({
124
+ name: z.string().describe('The name of the component to retrieve'),
125
+ }),
126
+ annotations: {readOnlyHint: true},
127
+ },
128
+ async ({name}) => {
129
+ const components = listComponents()
130
+ const match = components.find(component => {
131
+ return component.name === name || component.name.toLowerCase() === name.toLowerCase()
132
+ })
133
+ if (!match) {
134
+ return {
135
+ isError: true,
136
+ errorMessage: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`,
137
+ content: [],
138
+ }
139
+ }
140
+ try {
141
+ const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
142
+ const llmsResponse = await fetch(llmsUrl)
143
+ if (llmsResponse.ok) {
144
+ const llmsText = await llmsResponse.text()
145
+ return {
146
+ content: [
147
+ {
148
+ type: 'text',
149
+ text: llmsText,
150
+ },
151
+ ],
152
+ }
153
+ }
154
+ } catch (_: unknown) {
155
+ // If there's an error fetching or processing the llms.txt, we fall back to a generic error message.
156
+ }
157
+
132
158
  return {
133
159
  isError: true,
134
- errorMessage: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`,
160
+ errorMessage: `There was an error fetching documentation for ${name}. Ensure the component exists.`,
135
161
  content: [],
136
162
  }
137
- }
138
- try {
139
- const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
140
- const llmsResponse = await fetch(llmsUrl)
141
- if (llmsResponse.ok) {
142
- const llmsText = await llmsResponse.text()
163
+ },
164
+ )
165
+
166
+ server.registerTool(
167
+ 'get_component_batch',
168
+ {
169
+ description:
170
+ 'Retrieve official documentation and usage details for 2 to 10 React components from the @primer/react package in one call. Use get_component for exactly one component.',
171
+ inputSchema: z.object({
172
+ names: z
173
+ .array(z.string())
174
+ .min(2)
175
+ .max(10)
176
+ .describe('Component names to retrieve (e.g. ["ActionMenu", "Button", "Pagination"])'),
177
+ }),
178
+ annotations: {readOnlyHint: true},
179
+ },
180
+ async ({names}) => {
181
+ const seenNames = new Set<string>()
182
+ const deduped = names.filter(name => {
183
+ const normalizedName = name.toLowerCase()
184
+ if (seenNames.has(normalizedName)) {
185
+ return false
186
+ }
187
+ seenNames.add(normalizedName)
188
+ return true
189
+ })
190
+ const components = listComponents()
191
+
192
+ const results = await Promise.all(
193
+ deduped.map(async name => {
194
+ const match = components.find(
195
+ component => component.name === name || component.name.toLowerCase() === name.toLowerCase(),
196
+ )
197
+ if (!match) {
198
+ return {
199
+ type: 'text' as const,
200
+ text: `## ${name}\n\nStatus: not-found. No component named \`${name}\` exists in @primer/react. Use \`list_components\` for valid names.`,
201
+ }
202
+ }
203
+
204
+ const controller = new AbortController()
205
+ const timeout = setTimeout(() => controller.abort(), 10_000)
206
+
207
+ try {
208
+ const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
209
+ const llmsResponse = await fetch(llmsUrl, {signal: controller.signal})
210
+ if (llmsResponse.ok) {
211
+ const text = await llmsResponse.text()
212
+ return {type: 'text' as const, text}
213
+ }
214
+ } catch (_: unknown) {
215
+ // Fall through to the in-band error result so other component requests can succeed.
216
+ } finally {
217
+ clearTimeout(timeout)
218
+ }
219
+
220
+ return {
221
+ type: 'text' as const,
222
+ text: `## ${match.name}\n\nStatus: fetch-error. Failed to load documentation for ${match.name}.`,
223
+ }
224
+ }),
225
+ )
226
+
227
+ return {content: results}
228
+ },
229
+ )
230
+
231
+ server.registerTool(
232
+ 'get_component_examples',
233
+ {
234
+ description: 'Get examples for how to use a component from Primer React',
235
+ inputSchema: z.object({
236
+ name: z.string().describe('The name of the component to retrieve'),
237
+ }),
238
+ annotations: {readOnlyHint: true},
239
+ },
240
+ async ({name}) => {
241
+ const components = listComponents()
242
+ const match = components.find(component => {
243
+ return component.name === name
244
+ })
245
+ if (!match) {
143
246
  return {
144
247
  content: [
145
248
  {
146
249
  type: 'text',
147
- text: llmsText,
250
+ text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`,
148
251
  },
149
252
  ],
150
253
  }
151
254
  }
152
- } catch (_: unknown) {
153
- // If there's an error fetching or processing the llms.txt, we fall back to a generic error message.
154
- }
155
-
156
- return {
157
- isError: true,
158
- errorMessage: `There was an error fetching documentation for ${name}. Ensure the component exists.`,
159
- content: [],
160
- }
161
- },
162
- )
163
-
164
- server.registerTool(
165
- 'get_component_batch',
166
- {
167
- description:
168
- 'Retrieve official documentation and usage details for 2 to 10 React components from the @primer/react package in one call. Use get_component for exactly one component.',
169
- inputSchema: {
170
- names: z
171
- .array(z.string())
172
- .min(2)
173
- .max(10)
174
- .describe('Component names to retrieve (e.g. ["ActionMenu", "Button", "Pagination"])'),
175
- },
176
- annotations: {readOnlyHint: true},
177
- },
178
- async ({names}) => {
179
- const seenNames = new Set<string>()
180
- const deduped = names.filter(name => {
181
- const normalizedName = name.toLowerCase()
182
- if (seenNames.has(normalizedName)) {
183
- return false
184
- }
185
- seenNames.add(normalizedName)
186
- return true
187
- })
188
- const components = listComponents()
189
-
190
- const results = await Promise.all(
191
- deduped.map(async name => {
192
- const match = components.find(
193
- component => component.name === name || component.name.toLowerCase() === name.toLowerCase(),
194
- )
195
- if (!match) {
196
- return {
197
- type: 'text' as const,
198
- text: `## ${name}\n\nStatus: not-found. No component named \`${name}\` exists in @primer/react. Use \`list_components\` for valid names.`,
199
- }
200
- }
201
255
 
202
- const controller = new AbortController()
203
- const timeout = setTimeout(() => controller.abort(), 10_000)
256
+ const url = new URL(`/product/components/${match.id}`, 'https://primer.style')
257
+ const response = await fetch(url)
258
+ if (!response.ok) {
259
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
260
+ }
204
261
 
205
- try {
206
- const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style')
207
- const llmsResponse = await fetch(llmsUrl, {signal: controller.signal})
208
- if (llmsResponse.ok) {
209
- const text = await llmsResponse.text()
210
- return {type: 'text' as const, text}
211
- }
212
- } catch (_: unknown) {
213
- // Fall through to the in-band error result so other component requests can succeed.
214
- } finally {
215
- clearTimeout(timeout)
262
+ const html = await response.text()
263
+ if (!html) {
264
+ return {
265
+ content: [],
216
266
  }
267
+ }
217
268
 
269
+ const $ = cheerio.load(html)
270
+ const source = $('main').html()
271
+ if (!source) {
218
272
  return {
219
- type: 'text' as const,
220
- text: `## ${match.name}\n\nStatus: fetch-error. Failed to load documentation for ${match.name}.`,
273
+ content: [],
221
274
  }
222
- }),
223
- )
224
-
225
- return {content: results}
226
- },
227
- )
228
-
229
- server.registerTool(
230
- 'get_component_examples',
231
- {
232
- description: 'Get examples for how to use a component from Primer React',
233
- inputSchema: {
234
- name: z.string().describe('The name of the component to retrieve'),
235
- },
236
- annotations: {readOnlyHint: true},
237
- },
238
- async ({name}) => {
239
- const components = listComponents()
240
- const match = components.find(component => {
241
- return component.name === name
242
- })
243
- if (!match) {
275
+ }
276
+
277
+ const text = turndownService.turndown(source)
278
+
244
279
  return {
245
280
  content: [
246
281
  {
247
282
  type: 'text',
248
- text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`get_components\` tool.`,
283
+ text: `Here are some examples of how to use the \`${name}\` component from the @primer/react package:
284
+
285
+ ${text}`,
249
286
  },
250
287
  ],
251
288
  }
252
- }
289
+ },
290
+ )
291
+
292
+ server.registerTool(
293
+ 'get_component_usage_guidelines',
294
+ {
295
+ description: 'Get usage information for how to use a component from Primer',
296
+ inputSchema: z.object({
297
+ name: z.string().describe('The name of the component to retrieve'),
298
+ }),
299
+ annotations: {readOnlyHint: true},
300
+ },
301
+ async ({name}) => {
302
+ const components = listComponents()
303
+ const match = components.find(component => {
304
+ return component.name === name
305
+ })
306
+ if (!match) {
307
+ return {
308
+ content: [
309
+ {
310
+ type: 'text',
311
+ text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`get_components\` tool.`,
312
+ },
313
+ ],
314
+ }
315
+ }
253
316
 
254
- const url = new URL(`/product/components/${match.id}`, 'https://primer.style')
255
- const response = await fetch(url)
256
- if (!response.ok) {
257
- throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
258
- }
317
+ const url = new URL(`/product/components/${match.id}/guidelines`, 'https://primer.style')
318
+ const response = await fetch(url)
319
+ if (!response.ok) {
320
+ if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) {
321
+ return {
322
+ content: [
323
+ {
324
+ type: 'text',
325
+ text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`,
326
+ },
327
+ ],
328
+ }
329
+ }
259
330
 
260
- const html = await response.text()
261
- if (!html) {
262
- return {
263
- content: [],
331
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
264
332
  }
265
- }
266
333
 
267
- const $ = cheerio.load(html)
268
- const source = $('main').html()
269
- if (!source) {
270
- return {
271
- content: [],
334
+ const html = await response.text()
335
+ if (!html) {
336
+ return {
337
+ content: [],
338
+ }
272
339
  }
273
- }
274
340
 
275
- const text = turndownService.turndown(source)
341
+ const $ = cheerio.load(html)
342
+ const source = $('main').html()
343
+ if (!source) {
344
+ return {
345
+ content: [],
346
+ }
347
+ }
276
348
 
277
- return {
278
- content: [
279
- {
280
- type: 'text',
281
- text: `Here are some examples of how to use the \`${name}\` component from the @primer/react package:
349
+ const text = turndownService.turndown(source)
282
350
 
283
- ${text}`,
284
- },
285
- ],
286
- }
287
- },
288
- )
289
-
290
- server.registerTool(
291
- 'get_component_usage_guidelines',
292
- {
293
- description: 'Get usage information for how to use a component from Primer',
294
- inputSchema: {
295
- name: z.string().describe('The name of the component to retrieve'),
296
- },
297
- annotations: {readOnlyHint: true},
298
- },
299
- async ({name}) => {
300
- const components = listComponents()
301
- const match = components.find(component => {
302
- return component.name === name
303
- })
304
- if (!match) {
305
351
  return {
306
352
  content: [
307
353
  {
308
354
  type: 'text',
309
- text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`get_components\` tool.`,
355
+ text: `Here are the usage guidelines for the \`${name}\` component from the @primer/react package:
356
+
357
+ ${text}`,
310
358
  },
311
359
  ],
312
360
  }
313
- }
314
-
315
- const url = new URL(`/product/components/${match.id}/guidelines`, 'https://primer.style')
316
- const response = await fetch(url)
317
- if (!response.ok) {
318
- if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) {
361
+ },
362
+ )
363
+
364
+ server.registerTool(
365
+ 'get_component_accessibility_guidelines',
366
+ {
367
+ description:
368
+ 'Retrieve accessibility guidelines and best practices for a specific component from the @primer/react package by its name. Use this tool to get official accessibility recommendations, usage tips, and requirements to ensure your UI components are inclusive and meet accessibility standards.',
369
+ inputSchema: z.object({
370
+ name: z.string().describe('The name of the component to retrieve'),
371
+ }),
372
+ annotations: {readOnlyHint: true},
373
+ },
374
+ async ({name}) => {
375
+ const components = listComponents()
376
+ const match = components.find(component => {
377
+ return component.name === name
378
+ })
379
+ if (!match) {
319
380
  return {
320
381
  content: [
321
382
  {
322
383
  type: 'text',
323
- text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`,
384
+ text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`,
324
385
  },
325
386
  ],
326
387
  }
327
388
  }
328
389
 
329
- throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
330
- }
390
+ const url = new URL(`/product/components/${match.id}/accessibility`, 'https://primer.style')
391
+ const response = await fetch(url)
392
+ if (!response.ok) {
393
+ if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) {
394
+ return {
395
+ content: [
396
+ {
397
+ type: 'text',
398
+ text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`,
399
+ },
400
+ ],
401
+ }
402
+ }
331
403
 
332
- const html = await response.text()
333
- if (!html) {
334
- return {
335
- content: [],
404
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
336
405
  }
337
- }
338
406
 
339
- const $ = cheerio.load(html)
340
- const source = $('main').html()
341
- if (!source) {
342
- return {
343
- content: [],
407
+ const html = await response.text()
408
+ if (!html) {
409
+ return {
410
+ content: [],
411
+ }
344
412
  }
345
- }
346
413
 
347
- const text = turndownService.turndown(source)
414
+ const $ = cheerio.load(html)
415
+ const source = $('main').html()
416
+ if (!source) {
417
+ return {
418
+ content: [],
419
+ }
420
+ }
348
421
 
349
- return {
350
- content: [
351
- {
352
- type: 'text',
353
- text: `Here are the usage guidelines for the \`${name}\` component from the @primer/react package:
422
+ const text = turndownService.turndown(source)
423
+
424
+ return {
425
+ content: [
426
+ {
427
+ type: 'text',
428
+ text: `Here are the accessibility guidelines for the \`${name}\` component from the @primer/react package:
354
429
 
355
430
  ${text}`,
356
- },
357
- ],
358
- }
359
- },
360
- )
361
-
362
- server.registerTool(
363
- 'get_component_accessibility_guidelines',
364
- {
365
- description:
366
- 'Retrieve accessibility guidelines and best practices for a specific component from the @primer/react package by its name. Use this tool to get official accessibility recommendations, usage tips, and requirements to ensure your UI components are inclusive and meet accessibility standards.',
367
- inputSchema: {
368
- name: z.string().describe('The name of the component to retrieve'),
431
+ },
432
+ ],
433
+ }
434
+ },
435
+ )
436
+
437
+ // -----------------------------------------------------------------------------
438
+ // Patterns
439
+ // -----------------------------------------------------------------------------
440
+ server.registerTool(
441
+ 'list_patterns',
442
+ {
443
+ description:
444
+ 'List all of the patterns available from Primer React. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.',
445
+ annotations: {readOnlyHint: true},
369
446
  },
370
- annotations: {readOnlyHint: true},
371
- },
372
- async ({name}) => {
373
- const components = listComponents()
374
- const match = components.find(component => {
375
- return component.name === name
376
- })
377
- if (!match) {
447
+ async () => {
448
+ const all = listPatterns()
449
+ const scenario = all.filter(pattern => pattern.category === 'scenario').map(pattern => `- ${pattern.name}`)
450
+ const ui = all.filter(pattern => pattern.category === 'ui').map(pattern => `- ${pattern.name}`)
378
451
  return {
379
452
  content: [
380
453
  {
381
454
  type: 'text',
382
- text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`,
455
+ text: `The following patterns are available from \`@primer/react\` for use in TypeScript projects. Scenario patterns describe specific user tasks. Prefer a scenario pattern when one fits the task, and fall back to the UI patterns otherwise.
456
+
457
+ ## Scenario patterns
458
+
459
+ ${scenario.join('\n')}
460
+
461
+ ## UI patterns
462
+
463
+ ${ui.join('\n')}`,
383
464
  },
384
465
  ],
385
466
  }
386
- }
387
-
388
- const url = new URL(`/product/components/${match.id}/accessibility`, 'https://primer.style')
389
- const response = await fetch(url)
390
- if (!response.ok) {
391
- if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) {
467
+ },
468
+ )
469
+
470
+ server.registerTool(
471
+ 'get_pattern',
472
+ {
473
+ description:
474
+ 'Get a specific pattern by name. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.',
475
+ inputSchema: z.object({
476
+ name: z.string().describe('The name of the pattern to retrieve'),
477
+ }),
478
+ annotations: {readOnlyHint: true},
479
+ },
480
+ async ({name}) => {
481
+ const patterns = listPatterns()
482
+ // Resolve scenario patterns first so a name clash favours the scenario pattern.
483
+ const match =
484
+ patterns.find(pattern => pattern.category === 'scenario' && pattern.name === name) ??
485
+ patterns.find(pattern => pattern.name === name)
486
+ if (!match) {
392
487
  return {
393
488
  content: [
394
489
  {
395
490
  type: 'text',
396
- text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`,
491
+ text: `There is no pattern named \`${name}\` in the @primer/react package. For a full list of patterns, use the \`list_patterns\` tool.`,
397
492
  },
398
493
  ],
399
494
  }
400
495
  }
401
496
 
402
- throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
403
- }
404
-
405
- const html = await response.text()
406
- if (!html) {
407
- return {
408
- content: [],
497
+ const basePath = match.category === 'scenario' ? 'scenario-patterns' : 'ui-patterns'
498
+ const url = new URL(`/product/${basePath}/${match.id}`, 'https://primer.style')
499
+ const response = await fetch(url)
500
+ if (!response.ok) {
501
+ throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
409
502
  }
410
- }
411
503
 
412
- const $ = cheerio.load(html)
413
- const source = $('main').html()
414
- if (!source) {
415
- return {
416
- content: [],
504
+ const html = await response.text()
505
+ if (!html) {
506
+ return {
507
+ content: [],
508
+ }
417
509
  }
418
- }
419
-
420
- const text = turndownService.turndown(source)
421
510
 
422
- return {
423
- content: [
424
- {
425
- type: 'text',
426
- text: `Here are the accessibility guidelines for the \`${name}\` component from the @primer/react package:
427
-
428
- ${text}`,
429
- },
430
- ],
431
- }
432
- },
433
- )
434
-
435
- // -----------------------------------------------------------------------------
436
- // Patterns
437
- // -----------------------------------------------------------------------------
438
- server.registerTool(
439
- 'list_patterns',
440
- {
441
- description:
442
- 'List all of the patterns available from Primer React. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.',
443
- annotations: {readOnlyHint: true},
444
- },
445
- async () => {
446
- const all = listPatterns()
447
- const scenario = all.filter(pattern => pattern.category === 'scenario').map(pattern => `- ${pattern.name}`)
448
- const ui = all.filter(pattern => pattern.category === 'ui').map(pattern => `- ${pattern.name}`)
449
- return {
450
- content: [
451
- {
452
- type: 'text',
453
- text: `The following patterns are available from \`@primer/react\` for use in TypeScript projects. Scenario patterns describe specific user tasks. Prefer a scenario pattern when one fits the task, and fall back to the UI patterns otherwise.
454
-
455
- ## Scenario patterns
456
-
457
- ${scenario.join('\n')}
511
+ const $ = cheerio.load(html)
512
+ const source = $('main').html()
513
+ if (!source) {
514
+ return {
515
+ content: [],
516
+ }
517
+ }
458
518
 
459
- ## UI patterns
519
+ const text = turndownService.turndown(source)
460
520
 
461
- ${ui.join('\n')}`,
462
- },
463
- ],
464
- }
465
- },
466
- )
467
-
468
- server.registerTool(
469
- 'get_pattern',
470
- {
471
- description:
472
- 'Get a specific pattern by name. Scenario patterns describe specific user tasks (copy, delete, filter, search). Prefer a scenario pattern when one fits the task, and fall back to the more generic UI patterns otherwise.',
473
- inputSchema: {
474
- name: z.string().describe('The name of the pattern to retrieve'),
475
- },
476
- annotations: {readOnlyHint: true},
477
- },
478
- async ({name}) => {
479
- const patterns = listPatterns()
480
- // Resolve scenario patterns first so a name clash favours the scenario pattern.
481
- const match =
482
- patterns.find(pattern => pattern.category === 'scenario' && pattern.name === name) ??
483
- patterns.find(pattern => pattern.name === name)
484
- if (!match) {
485
521
  return {
486
522
  content: [
487
523
  {
488
524
  type: 'text',
489
- text: `There is no pattern named \`${name}\` in the @primer/react package. For a full list of patterns, use the \`list_patterns\` tool.`,
525
+ text: `Here are the guidelines for the \`${name}\` pattern for Primer:
526
+
527
+ ${text}`,
490
528
  },
491
529
  ],
492
530
  }
493
- }
531
+ },
532
+ )
533
+
534
+ // -----------------------------------------------------------------------------
535
+ // Design Tokens
536
+ // -----------------------------------------------------------------------------
537
+ server.registerTool(
538
+ 'find_tokens',
539
+ {
540
+ description:
541
+ 'Search for specific tokens. Tip: If you only provide a \'group\' and leave \'query\' empty, it returns all tokens in that category. Avoid property-by-property searching. COLOR RESOLUTION: If a user asks for "pink" or "blue", do not search for the color name. Use the semantic intent: blue->accent, red->danger, green->success. Always check both "emphasis" and "muted" variants for background colors. After identifying tokens and writing CSS, you MUST validate the result using lint_css.',
542
+ inputSchema: z.object({
543
+ query: z
544
+ .string()
545
+ .optional()
546
+ .default('')
547
+ .describe('Search keywords (e.g., "danger border", "success background")'),
548
+ group: z.string().optional().describe('Filter by group (e.g., "fgColor", "border")'),
549
+ limit: z
550
+ .number()
551
+ .int()
552
+ .min(1)
553
+ .max(100)
554
+ .optional()
555
+ .default(15)
556
+ .describe('Maximum results to return to stay within context limits'),
557
+ }),
558
+ annotations: {readOnlyHint: true},
559
+ },
560
+ async ({query, group, limit}) => {
561
+ // Resolve group via aliases
562
+ const resolvedGroup = group ? GROUP_ALIASES[group.toLowerCase().replace(/\s+/g, '')] || group : undefined
563
+
564
+ // Split query into keywords and extract any that match a known group
565
+ const rawKeywords = query
566
+ .toLowerCase()
567
+ .split(/\s+/)
568
+ .filter(k => k.length > 0)
569
+
570
+ let effectiveGroup = resolvedGroup
571
+ const filteredKeywords: string[] = []
572
+
573
+ for (const kw of rawKeywords) {
574
+ const normalized = kw.replace(/\s+/g, '')
575
+ const aliasMatch = GROUP_ALIASES[normalized]
576
+ if (aliasMatch && !effectiveGroup) {
577
+ effectiveGroup = aliasMatch
578
+ } else {
579
+ filteredKeywords.push(kw)
580
+ }
581
+ }
582
+
583
+ // Guard: no query and no group → ask user to provide at least one
584
+ if (filteredKeywords.length === 0 && !effectiveGroup) {
585
+ return {
586
+ content: [
587
+ {
588
+ type: 'text',
589
+ text: 'Please provide a query, a group, or both. Call `get_design_token_specs` to see available token groups.',
590
+ },
591
+ ],
592
+ }
593
+ }
494
594
 
495
- const basePath = match.category === 'scenario' ? 'scenario-patterns' : 'ui-patterns'
496
- const url = new URL(`/product/${basePath}/${match.id}`, 'https://primer.style')
497
- const response = await fetch(url)
498
- if (!response.ok) {
499
- throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
500
- }
595
+ // Group-only search: return all tokens in the group
596
+ const isGroupOnly = filteredKeywords.length === 0 && effectiveGroup
597
+ let results: TokenWithGuidelines[]
501
598
 
502
- const html = await response.text()
503
- if (!html) {
504
- return {
505
- content: [],
599
+ if (isGroupOnly) {
600
+ results = allTokensWithGuidelines.filter(token => tokenMatchesGroup(token, effectiveGroup!))
601
+ } else {
602
+ results = searchTokens(allTokensWithGuidelines, filteredKeywords.join(' '), effectiveGroup)
506
603
  }
507
- }
508
604
 
509
- const $ = cheerio.load(html)
510
- const source = $('main').html()
511
- if (!source) {
512
- return {
513
- content: [],
605
+ if (results.length === 0) {
606
+ const validGroups = getValidGroupsList(allTokensWithGuidelines)
607
+
608
+ return {
609
+ content: [
610
+ {
611
+ type: 'text',
612
+ text: `No tokens found matching "${query}"${effectiveGroup ? ` in group "${effectiveGroup}"` : ''}.
613
+
614
+ ### 💡 Available Groups:
615
+ ${validGroups}
616
+
617
+ ### Troubleshooting for AI:
618
+ 1. **Multi-word Queries**: Search keywords use 'AND' logic. If searching "text shorthand typography" fails, try a single keyword like "shorthand" within the "text" group.
619
+ 2. **Property Mismatch**: Do not search for CSS properties like "offset", "padding", or "font-size". Use semantic intent keywords: "danger", "muted", "emphasis".
620
+ 3. **Typography**: Remember that \`caption\`, \`display\`, and \`code\` groups do NOT support size suffixes. Use the base shorthand only.
621
+ 4. **Group Intent**: Use the \`group\` parameter instead of putting group names in the \`query\` string (e.g., use group: "stack" instead of query: "stack padding").`,
622
+ },
623
+ ],
624
+ }
514
625
  }
515
- }
516
626
 
517
- const text = turndownService.turndown(source)
627
+ const limitedResults = results.slice(0, limit)
518
628
 
519
- return {
520
- content: [
521
- {
522
- type: 'text',
523
- text: `Here are the guidelines for the \`${name}\` pattern for Primer:
629
+ let output: string
524
630
 
525
- ${text}`,
526
- },
527
- ],
528
- }
529
- },
530
- )
531
-
532
- // -----------------------------------------------------------------------------
533
- // Design Tokens
534
- // -----------------------------------------------------------------------------
535
- server.registerTool(
536
- 'find_tokens',
537
- {
538
- description:
539
- 'Search for specific tokens. Tip: If you only provide a \'group\' and leave \'query\' empty, it returns all tokens in that category. Avoid property-by-property searching. COLOR RESOLUTION: If a user asks for "pink" or "blue", do not search for the color name. Use the semantic intent: blue->accent, red->danger, green->success. Always check both "emphasis" and "muted" variants for background colors. After identifying tokens and writing CSS, you MUST validate the result using lint_css.',
540
- inputSchema: {
541
- query: z
542
- .string()
543
- .optional()
544
- .default('')
545
- .describe('Search keywords (e.g., "danger border", "success background")'),
546
- group: z.string().optional().describe('Filter by group (e.g., "fgColor", "border")'),
547
- limit: z
548
- .number()
549
- .int()
550
- .min(1)
551
- .max(100)
552
- .optional()
553
- .default(15)
554
- .describe('Maximum results to return to stay within context limits'),
555
- },
556
- annotations: {readOnlyHint: true},
557
- },
558
- async ({query, group, limit}) => {
559
- // Resolve group via aliases
560
- const resolvedGroup = group ? GROUP_ALIASES[group.toLowerCase().replace(/\s+/g, '')] || group : undefined
561
-
562
- // Split query into keywords and extract any that match a known group
563
- const rawKeywords = query
564
- .toLowerCase()
565
- .split(/\s+/)
566
- .filter(k => k.length > 0)
567
-
568
- let effectiveGroup = resolvedGroup
569
- const filteredKeywords: string[] = []
570
-
571
- for (const kw of rawKeywords) {
572
- const normalized = kw.replace(/\s+/g, '')
573
- const aliasMatch = GROUP_ALIASES[normalized]
574
- if (aliasMatch && !effectiveGroup) {
575
- effectiveGroup = aliasMatch
631
+ if (!query) {
632
+ output = `Found ${results.length} token(s). Showing top ${limitedResults.length}:\n\n`
576
633
  } else {
577
- filteredKeywords.push(kw)
634
+ output = `Found ${results.length} token(s) matching "${query}". Showing top ${limitedResults.length}:\n\n`
635
+ }
636
+ output += formatBundle(limitedResults)
637
+
638
+ if (results.length > limit) {
639
+ output += `\n\n*...and ${results.length - limit} more matches. Use more specific keywords to narrow the search.*`
578
640
  }
579
- }
580
641
 
581
- // Guard: no query and no group → ask user to provide at least one
582
- if (filteredKeywords.length === 0 && !effectiveGroup) {
583
642
  return {
584
- content: [
585
- {
586
- type: 'text',
587
- text: 'Please provide a query, a group, or both. Call `get_design_token_specs` to see available token groups.',
588
- },
589
- ],
643
+ content: [{type: 'text', text: output}],
590
644
  }
591
- }
645
+ },
646
+ )
647
+
648
+ server.registerTool(
649
+ 'get_token_group_bundle',
650
+ {
651
+ description:
652
+ "PREFERRED FOR COMPONENTS. Fetch all tokens for complex UI (e.g., Dialogs, Cards) in one call by providing an array of groups like ['overlay', 'shadow']. Use this instead of multiple find_tokens calls to save context.",
653
+ inputSchema: z.object({
654
+ groups: z.array(z.string()).describe('Array of group names (e.g., ["overlay", "shadow", "focus"])'),
655
+ }),
656
+ annotations: {readOnlyHint: true},
657
+ },
658
+ async ({groups}) => {
659
+ // Normalize and resolve aliases
660
+ const resolvedGroups = groups.map(g => {
661
+ const normalized = g.toLowerCase().replace(/\s+/g, '')
662
+ return GROUP_ALIASES[normalized] || g
663
+ })
592
664
 
593
- // Group-only search: return all tokens in the group
594
- const isGroupOnly = filteredKeywords.length === 0 && effectiveGroup
595
- let results: TokenWithGuidelines[]
665
+ // Filter tokens matching any of the resolved groups
666
+ const matched = allTokensWithGuidelines.filter(token => resolvedGroups.some(rg => tokenMatchesGroup(token, rg)))
596
667
 
597
- if (isGroupOnly) {
598
- results = allTokensWithGuidelines.filter(token => tokenMatchesGroup(token, effectiveGroup!))
599
- } else {
600
- results = searchTokens(allTokensWithGuidelines, filteredKeywords.join(' '), effectiveGroup)
601
- }
668
+ if (matched.length === 0) {
669
+ const validGroups = getValidGroupsList(allTokensWithGuidelines)
670
+ return {
671
+ content: [
672
+ {
673
+ type: 'text',
674
+ text: `No tokens found for groups: ${groups.join(', ')}.\n\n### Valid Groups:\n${validGroups}`,
675
+ },
676
+ ],
677
+ }
678
+ }
602
679
 
603
- if (results.length === 0) {
604
- const validGroups = getValidGroupsList(allTokensWithGuidelines)
680
+ let text = `Found ${matched.length} token(s) across ${resolvedGroups.length} group(s):\n\n${formatBundle(matched)}`
681
+
682
+ const activeHints = resolvedGroups.map(g => groupHints[g]).filter(Boolean)
683
+
684
+ if (activeHints.length > 0) {
685
+ text += `\n\n### ⚠️ Usage Guidance:\n${activeHints.map(h => `- ${h}`).join('\n')}`
686
+ }
605
687
 
606
688
  return {
607
- content: [
608
- {
609
- type: 'text',
610
- text: `No tokens found matching "${query}"${effectiveGroup ? ` in group "${effectiveGroup}"` : ''}.
689
+ content: [{type: 'text', text}],
690
+ }
691
+ },
692
+ )
693
+
694
+ server.registerTool(
695
+ 'get_design_token_specs',
696
+ {
697
+ description:
698
+ 'CRITICAL: CALL THIS FIRST. Provides the logic matrix and the list of valid group names. You cannot search accurately without this map.',
699
+ annotations: {readOnlyHint: true},
700
+ },
701
+ async () => {
702
+ const groups = listTokenGroups()
703
+ const customRules = getDesignTokenSpecsText(groups)
704
+ let text: string
705
+ try {
706
+ const upstreamGuide = loadDesignTokensGuide()
707
+ text = `${customRules}\n\n---\n\n${upstreamGuide}`
708
+ } catch {
709
+ text = customRules
710
+ }
611
711
 
612
- ### 💡 Available Groups:
613
- ${validGroups}
712
+ return {
713
+ content: [{type: 'text', text}],
714
+ }
715
+ },
716
+ )
717
+
718
+ server.registerTool(
719
+ 'get_token_usage_patterns',
720
+ {
721
+ description:
722
+ 'Provides "Golden Example" CSS for core patterns: Button (Interactions) and Stack (Layout). Use this to understand how to apply the Logic Matrix, Motion, and Spacing scales.',
723
+ annotations: {readOnlyHint: true},
724
+ },
725
+ async () => {
726
+ const customPatterns = getTokenUsagePatternsText()
727
+ let text: string
728
+ try {
729
+ const guide = loadDesignTokensGuide()
730
+ const goldenExampleMatch = guide.match(/## Golden Example[\s\S]*?(?=\n## |$)/)
731
+ if (goldenExampleMatch) {
732
+ text = `${customPatterns}\n\n---\n\n${goldenExampleMatch[0].trim()}`
733
+ } else {
734
+ text = customPatterns
735
+ }
736
+ } catch {
737
+ text = customPatterns
738
+ }
614
739
 
615
- ### Troubleshooting for AI:
616
- 1. **Multi-word Queries**: Search keywords use 'AND' logic. If searching "text shorthand typography" fails, try a single keyword like "shorthand" within the "text" group.
617
- 2. **Property Mismatch**: Do not search for CSS properties like "offset", "padding", or "font-size". Use semantic intent keywords: "danger", "muted", "emphasis".
618
- 3. **Typography**: Remember that \`caption\`, \`display\`, and \`code\` groups do NOT support size suffixes. Use the base shorthand only.
619
- 4. **Group Intent**: Use the \`group\` parameter instead of putting group names in the \`query\` string (e.g., use group: "stack" instead of query: "stack padding").`,
620
- },
621
- ],
740
+ return {
741
+ content: [{type: 'text', text}],
622
742
  }
623
- }
624
-
625
- const limitedResults = results.slice(0, limit)
626
-
627
- let output: string
628
-
629
- if (!query) {
630
- output = `Found ${results.length} token(s). Showing top ${limitedResults.length}:\n\n`
631
- } else {
632
- output = `Found ${results.length} token(s) matching "${query}". Showing top ${limitedResults.length}:\n\n`
633
- }
634
- output += formatBundle(limitedResults)
635
-
636
- if (results.length > limit) {
637
- output += `\n\n*...and ${results.length - limit} more matches. Use more specific keywords to narrow the search.*`
638
- }
639
-
640
- return {
641
- content: [{type: 'text', text: output}],
642
- }
643
- },
644
- )
645
-
646
- server.registerTool(
647
- 'get_token_group_bundle',
648
- {
649
- description:
650
- "PREFERRED FOR COMPONENTS. Fetch all tokens for complex UI (e.g., Dialogs, Cards) in one call by providing an array of groups like ['overlay', 'shadow']. Use this instead of multiple find_tokens calls to save context.",
651
- inputSchema: {
652
- groups: z.array(z.string()).describe('Array of group names (e.g., ["overlay", "shadow", "focus"])'),
653
743
  },
654
- annotations: {readOnlyHint: true},
655
- },
656
- async ({groups}) => {
657
- // Normalize and resolve aliases
658
- const resolvedGroups = groups.map(g => {
659
- const normalized = g.toLowerCase().replace(/\s+/g, '')
660
- return GROUP_ALIASES[normalized] || g
661
- })
662
-
663
- // Filter tokens matching any of the resolved groups
664
- const matched = allTokensWithGuidelines.filter(token => resolvedGroups.some(rg => tokenMatchesGroup(token, rg)))
665
-
666
- if (matched.length === 0) {
667
- const validGroups = getValidGroupsList(allTokensWithGuidelines)
744
+ )
745
+
746
+ server.registerTool(
747
+ 'lint_css',
748
+ {
749
+ description:
750
+ 'REQUIRED FINAL STEP. Use this to validate your CSS. You cannot complete a task involving CSS without a successful run of this tool.',
751
+ inputSchema: z.object({css: z.string()}),
752
+ annotations: {readOnlyHint: true},
753
+ },
754
+ async ({css}) => {
755
+ try {
756
+ // --fix flag tells Stylelint to repair what it can
757
+ const {stdout} = await runStylelint(css)
758
+
759
+ return {
760
+ content: [
761
+ {
762
+ type: 'text',
763
+ text: stdout || '✅ Stylelint passed (or was successfully autofixed).',
764
+ },
765
+ ],
766
+ }
767
+ } catch (error: unknown) {
768
+ // If Stylelint still has errors it CANNOT fix, it will land here
769
+ const errorOutput =
770
+ error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error)
771
+ return {
772
+ content: [
773
+ {
774
+ type: 'text',
775
+ text: `❌ Errors without autofix remaining:\n${errorOutput}`,
776
+ },
777
+ ],
778
+ }
779
+ }
780
+ },
781
+ )
782
+
783
+ // -----------------------------------------------------------------------------
784
+ // Foundations
785
+ // -----------------------------------------------------------------------------
786
+ server.registerTool(
787
+ 'get_color_usage',
788
+ {description: 'Get the guidelines for how to apply color to a user interface', annotations: {readOnlyHint: true}},
789
+ async () => {
790
+ const url = new URL(`/product/getting-started/foundations/color-usage`, 'https://primer.style')
791
+ const response = await fetch(url)
792
+ if (!response.ok) {
793
+ throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
794
+ }
795
+
796
+ const html = await response.text()
797
+ if (!html) {
798
+ return {
799
+ content: [],
800
+ }
801
+ }
802
+
803
+ const $ = cheerio.load(html)
804
+ const source = $('main').html()
805
+ if (!source) {
806
+ return {
807
+ content: [],
808
+ }
809
+ }
810
+
811
+ const text = turndownService.turndown(source)
812
+
668
813
  return {
669
814
  content: [
670
815
  {
671
816
  type: 'text',
672
- text: `No tokens found for groups: ${groups.join(', ')}.\n\n### Valid Groups:\n${validGroups}`,
817
+ text: `Here is the documentation for color usage in Primer:\n\n${text}`,
673
818
  },
674
819
  ],
675
820
  }
676
- }
677
-
678
- let text = `Found ${matched.length} token(s) across ${resolvedGroups.length} group(s):\n\n${formatBundle(matched)}`
679
-
680
- const activeHints = resolvedGroups.map(g => groupHints[g]).filter(Boolean)
681
-
682
- if (activeHints.length > 0) {
683
- text += `\n\n### ⚠️ Usage Guidance:\n${activeHints.map(h => `- ${h}`).join('\n')}`
684
- }
685
-
686
- return {
687
- content: [{type: 'text', text}],
688
- }
689
- },
690
- )
691
-
692
- server.registerTool(
693
- 'get_design_token_specs',
694
- {
695
- description:
696
- 'CRITICAL: CALL THIS FIRST. Provides the logic matrix and the list of valid group names. You cannot search accurately without this map.',
697
- annotations: {readOnlyHint: true},
698
- },
699
- async () => {
700
- const groups = listTokenGroups()
701
- const customRules = getDesignTokenSpecsText(groups)
702
- let text: string
703
- try {
704
- const upstreamGuide = loadDesignTokensGuide()
705
- text = `${customRules}\n\n---\n\n${upstreamGuide}`
706
- } catch {
707
- text = customRules
708
- }
709
-
710
- return {
711
- content: [{type: 'text', text}],
712
- }
713
- },
714
- )
715
-
716
- server.registerTool(
717
- 'get_token_usage_patterns',
718
- {
719
- description:
720
- 'Provides "Golden Example" CSS for core patterns: Button (Interactions) and Stack (Layout). Use this to understand how to apply the Logic Matrix, Motion, and Spacing scales.',
721
- annotations: {readOnlyHint: true},
722
- },
723
- async () => {
724
- const customPatterns = getTokenUsagePatternsText()
725
- let text: string
726
- try {
727
- const guide = loadDesignTokensGuide()
728
- const goldenExampleMatch = guide.match(/## Golden Example[\s\S]*?(?=\n## |$)/)
729
- if (goldenExampleMatch) {
730
- text = `${customPatterns}\n\n---\n\n${goldenExampleMatch[0].trim()}`
731
- } else {
732
- text = customPatterns
821
+ },
822
+ )
823
+
824
+ server.registerTool(
825
+ 'get_typography_usage',
826
+ {
827
+ description: 'Get the guidelines for how to apply typography to a user interface',
828
+ annotations: {readOnlyHint: true},
829
+ },
830
+ async () => {
831
+ const url = new URL(`/product/getting-started/foundations/typography`, 'https://primer.style')
832
+ const response = await fetch(url)
833
+ if (!response.ok) {
834
+ throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
835
+ }
836
+
837
+ const html = await response.text()
838
+ if (!html) {
839
+ return {
840
+ content: [],
841
+ }
733
842
  }
734
- } catch {
735
- text = customPatterns
736
- }
737
-
738
- return {
739
- content: [{type: 'text', text}],
740
- }
741
- },
742
- )
743
-
744
- server.registerTool(
745
- 'lint_css',
746
- {
747
- description:
748
- 'REQUIRED FINAL STEP. Use this to validate your CSS. You cannot complete a task involving CSS without a successful run of this tool.',
749
- inputSchema: {css: z.string()},
750
- annotations: {readOnlyHint: true},
751
- },
752
- async ({css}) => {
753
- try {
754
- // --fix flag tells Stylelint to repair what it can
755
- const {stdout} = await runStylelint(css)
843
+
844
+ const $ = cheerio.load(html)
845
+ const source = $('main').html()
846
+ if (!source) {
847
+ return {
848
+ content: [],
849
+ }
850
+ }
851
+
852
+ const text = turndownService.turndown(source)
756
853
 
757
854
  return {
758
855
  content: [
759
856
  {
760
857
  type: 'text',
761
- text: stdout || '✅ Stylelint passed (or was successfully autofixed).',
858
+ text: `Here is the documentation for typography usage in Primer:\n\n${text}`,
762
859
  },
763
860
  ],
764
861
  }
765
- } catch (error: unknown) {
766
- // If Stylelint still has errors it CANNOT fix, it will land here
767
- const errorOutput =
768
- error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error)
862
+ },
863
+ )
864
+
865
+ // -----------------------------------------------------------------------------
866
+ // Icons
867
+ // -----------------------------------------------------------------------------
868
+ server.registerTool(
869
+ 'list_icons',
870
+ {
871
+ description: 'List all of the icons (octicons) available from Primer Octicons React',
872
+ annotations: {readOnlyHint: true},
873
+ },
874
+ async () => {
875
+ const icons = listIcons().map(icon => {
876
+ const keywords = icon.keywords.map(keyword => {
877
+ return `<keyword>${keyword}</keyword>`
878
+ })
879
+ const sizes = icon.heights.map(height => {
880
+ return `<size value="${height}"></size>`
881
+ })
882
+ return [`<icon name="${icon.name}">`, ...keywords, ...sizes, `</icon>`].join('\n')
883
+ })
884
+
769
885
  return {
770
886
  content: [
771
887
  {
772
888
  type: 'text',
773
- text: `❌ Errors without autofix remaining:\n${errorOutput}`,
889
+ text: `The following icons are available in the @primer/octicons-react package in TypeScript projects:
890
+
891
+ ${icons.join('\n')}
892
+
893
+ You can use the \`get_icon\` tool to get more information about a specific icon. You can use these components from the @primer/octicons-react package.`,
774
894
  },
775
895
  ],
776
896
  }
777
- }
778
- },
779
- )
780
-
781
- // -----------------------------------------------------------------------------
782
- // Foundations
783
- // -----------------------------------------------------------------------------
784
- server.registerTool(
785
- 'get_color_usage',
786
- {description: 'Get the guidelines for how to apply color to a user interface', annotations: {readOnlyHint: true}},
787
- async () => {
788
- const url = new URL(`/product/getting-started/foundations/color-usage`, 'https://primer.style')
789
- const response = await fetch(url)
790
- if (!response.ok) {
791
- throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
792
- }
793
-
794
- const html = await response.text()
795
- if (!html) {
796
- return {
797
- content: [],
897
+ },
898
+ )
899
+
900
+ server.registerTool(
901
+ 'get_icon',
902
+ {
903
+ description: 'Get a specific icon (octicon) by name from Primer',
904
+ inputSchema: z.object({
905
+ name: z.string().describe('The name of the icon to retrieve'),
906
+ size: z.string().optional().describe('The size of the icon to retrieve, e.g. "16"').default('16'),
907
+ }),
908
+ annotations: {readOnlyHint: true},
909
+ },
910
+ async ({name, size}) => {
911
+ const icons = listIcons()
912
+ const match = icons.find(icon => {
913
+ return icon.name === name || icon.name.toLowerCase() === name.toLowerCase()
914
+ })
915
+ if (!match) {
916
+ return {
917
+ content: [
918
+ {
919
+ type: 'text',
920
+ text: `There is no icon named \`${name}\` in the @primer/octicons-react package. For a full list of icons, use the \`get_icon\` tool.`,
921
+ },
922
+ ],
923
+ }
798
924
  }
799
- }
800
925
 
801
- const $ = cheerio.load(html)
802
- const source = $('main').html()
803
- if (!source) {
804
- return {
805
- content: [],
926
+ const url = new URL(`/octicons/icon/${match.name}-${size}`, 'https://primer.style')
927
+ const response = await fetch(url)
928
+ if (!response.ok) {
929
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
806
930
  }
807
- }
808
-
809
- const text = turndownService.turndown(source)
810
-
811
- return {
812
- content: [
813
- {
814
- type: 'text',
815
- text: `Here is the documentation for color usage in Primer:\n\n${text}`,
816
- },
817
- ],
818
- }
819
- },
820
- )
821
-
822
- server.registerTool(
823
- 'get_typography_usage',
824
- {
825
- description: 'Get the guidelines for how to apply typography to a user interface',
826
- annotations: {readOnlyHint: true},
827
- },
828
- async () => {
829
- const url = new URL(`/product/getting-started/foundations/typography`, 'https://primer.style')
830
- const response = await fetch(url)
831
- if (!response.ok) {
832
- throw new Error(`Failed to fetch ${url} - ${response.statusText}`)
833
- }
834
-
835
- const html = await response.text()
836
- if (!html) {
837
- return {
838
- content: [],
839
- }
840
- }
841
931
 
842
- const $ = cheerio.load(html)
843
- const source = $('main').html()
844
- if (!source) {
845
- return {
846
- content: [],
932
+ const html = await response.text()
933
+ if (!html) {
934
+ return {
935
+ content: [],
936
+ }
847
937
  }
848
- }
849
-
850
- const text = turndownService.turndown(source)
851
-
852
- return {
853
- content: [
854
- {
855
- type: 'text',
856
- text: `Here is the documentation for typography usage in Primer:\n\n${text}`,
857
- },
858
- ],
859
- }
860
- },
861
- )
862
-
863
- // -----------------------------------------------------------------------------
864
- // Icons
865
- // -----------------------------------------------------------------------------
866
- server.registerTool(
867
- 'list_icons',
868
- {
869
- description: 'List all of the icons (octicons) available from Primer Octicons React',
870
- annotations: {readOnlyHint: true},
871
- },
872
- async () => {
873
- const icons = listIcons().map(icon => {
874
- const keywords = icon.keywords.map(keyword => {
875
- return `<keyword>${keyword}</keyword>`
876
- })
877
- const sizes = icon.heights.map(height => {
878
- return `<size value="${height}"></size>`
879
- })
880
- return [`<icon name="${icon.name}">`, ...keywords, ...sizes, `</icon>`].join('\n')
881
- })
882
938
 
883
- return {
884
- content: [
885
- {
886
- type: 'text',
887
- text: `The following icons are available in the @primer/octicons-react package in TypeScript projects:
939
+ const $ = cheerio.load(html)
940
+ const source = $('main').html()
941
+ if (!source) {
942
+ return {
943
+ content: [],
944
+ }
945
+ }
888
946
 
889
- ${icons.join('\n')}
947
+ const text = turndownService.turndown(source)
890
948
 
891
- You can use the \`get_icon\` tool to get more information about a specific icon. You can use these components from the @primer/octicons-react package.`,
892
- },
893
- ],
894
- }
895
- },
896
- )
897
-
898
- server.registerTool(
899
- 'get_icon',
900
- {
901
- description: 'Get a specific icon (octicon) by name from Primer',
902
- inputSchema: {
903
- name: z.string().describe('The name of the icon to retrieve'),
904
- size: z.string().optional().describe('The size of the icon to retrieve, e.g. "16"').default('16'),
905
- },
906
- annotations: {readOnlyHint: true},
907
- },
908
- async ({name, size}) => {
909
- const icons = listIcons()
910
- const match = icons.find(icon => {
911
- return icon.name === name || icon.name.toLowerCase() === name.toLowerCase()
912
- })
913
- if (!match) {
914
949
  return {
915
950
  content: [
916
951
  {
917
952
  type: 'text',
918
- text: `There is no icon named \`${name}\` in the @primer/octicons-react package. For a full list of icons, use the \`get_icon\` tool.`,
953
+ text: `Here is the documentation for the \`${name}\` icon at size: \`${size}\`:
954
+ ${text}`,
919
955
  },
920
956
  ],
921
957
  }
922
- }
923
-
924
- const url = new URL(`/octicons/icon/${match.name}-${size}`, 'https://primer.style')
925
- const response = await fetch(url)
926
- if (!response.ok) {
927
- throw new Error(`Failed to fetch ${url}: ${response.statusText}`)
928
- }
929
-
930
- const html = await response.text()
931
- if (!html) {
932
- return {
933
- content: [],
934
- }
935
- }
936
-
937
- const $ = cheerio.load(html)
938
- const source = $('main').html()
939
- if (!source) {
958
+ },
959
+ )
960
+
961
+ // -----------------------------------------------------------------------------
962
+ // Coding guidelines
963
+ // -----------------------------------------------------------------------------
964
+ server.registerTool(
965
+ 'primer_coding_guidelines',
966
+ {
967
+ description: 'Get the guidelines when writing code that uses Primer or for UI code that you are creating',
968
+ annotations: {readOnlyHint: true},
969
+ },
970
+ async () => {
940
971
  return {
941
- content: [],
942
- }
943
- }
944
-
945
- const text = turndownService.turndown(source)
946
-
947
- return {
948
- content: [
949
- {
950
- type: 'text',
951
- text: `Here is the documentation for the \`${name}\` icon at size: \`${size}\`:
952
- ${text}`,
953
- },
954
- ],
955
- }
956
- },
957
- )
958
-
959
- // -----------------------------------------------------------------------------
960
- // Coding guidelines
961
- // -----------------------------------------------------------------------------
962
- server.registerTool(
963
- 'primer_coding_guidelines',
964
- {
965
- description: 'Get the guidelines when writing code that uses Primer or for UI code that you are creating',
966
- annotations: {readOnlyHint: true},
967
- },
968
- async () => {
969
- return {
970
- content: [
971
- {
972
- type: 'text',
973
- text: `When writing code that uses Primer, follow these guidelines:
972
+ content: [
973
+ {
974
+ type: 'text',
975
+ text: `When writing code that uses Primer, follow these guidelines:
974
976
 
975
977
  ## Design Tokens
976
978
 
@@ -992,64 +994,82 @@ The following list of coding guidelines must be followed:
992
994
  - Do not use the sx prop for styling components. Instead, use CSS Modules.
993
995
  - Do not use the Box component for styling components. Instead, use CSS Modules.
994
996
  `,
995
- },
996
- ],
997
- }
998
- },
999
- )
1000
-
1001
- // -----------------------------------------------------------------------------
1002
- // Accessibility
1003
- // -----------------------------------------------------------------------------
1004
-
1005
- /**
1006
- * The `review_alt_text` tool is experimental and may be removed in future versions.
1007
- *
1008
- * The intent of this tool is to assist products like Copilot Code Review and Copilot Coding Agent
1009
- * in reviewing both user- and AI-generated alt text for images, ensuring compliance with accessibility guidelines.
1010
- * This tool is not intended to replace human-generated alt text; rather, it supports the review process
1011
- * by providing suggestions for improvement. It should be used alongside human review, not as a substitute.
1012
- *
1013
- *
1014
- **/
1015
- server.registerTool(
1016
- 'review_alt_text',
1017
- {
1018
- description: 'Evaluates image alt text against accessibility best practices and context relevance.',
1019
- inputSchema: {
1020
- surroundingText: z.string().describe('Text surrounding the image, relevant to the image.'),
1021
- alt: z.string().describe('The alt text of the image being evaluated'),
1022
- image: z.string().describe('The image URL or file path being evaluated'),
997
+ },
998
+ ],
999
+ }
1000
+ },
1001
+ )
1002
+
1003
+ // -----------------------------------------------------------------------------
1004
+ // Accessibility
1005
+ // -----------------------------------------------------------------------------
1006
+
1007
+ /**
1008
+ * The `review_alt_text` tool is experimental and may be removed in future versions.
1009
+ *
1010
+ * The intent of this tool is to assist products like Copilot Code Review and Copilot Coding Agent
1011
+ * in reviewing both user- and AI-generated alt text for images, ensuring compliance with accessibility guidelines.
1012
+ * This tool is not intended to replace human-generated alt text; rather, it supports the review process
1013
+ * by providing suggestions for improvement. It should be used alongside human review, not as a substitute.
1014
+ *
1015
+ *
1016
+ **/
1017
+ server.registerTool(
1018
+ 'review_alt_text',
1019
+ {
1020
+ description: 'Evaluates image alt text against accessibility best practices and context relevance.',
1021
+ inputSchema: z.object({
1022
+ surroundingText: z.string().describe('Text surrounding the image, relevant to the image.'),
1023
+ alt: z.string().describe('The alt text of the image being evaluated'),
1024
+ image: z.string().describe('The image URL or file path being evaluated'),
1025
+ }),
1026
+ annotations: {readOnlyHint: true},
1023
1027
  },
1024
- annotations: {readOnlyHint: true},
1025
- },
1026
- async ({surroundingText, alt, image}) => {
1027
- // Call the LLM through MCP sampling
1028
- const response = await server.server.createMessage({
1029
- messages: [
1030
- {
1031
- role: 'user',
1032
- content: {
1028
+ async ({surroundingText, alt, image}, context) => {
1029
+ const response = inputResponse(context.mcpReq.inputResponses, 'altTextEvaluation')
1030
+ if (response.kind !== 'sampling') {
1031
+ return inputRequired({
1032
+ inputRequests: {
1033
+ altTextEvaluation: inputRequired.createMessage({
1034
+ messages: [
1035
+ {
1036
+ role: 'user',
1037
+ content: {
1038
+ type: 'text',
1039
+ text: `Does this alt text: '${alt}' meet accessibility guidelines and describe the image: ${image} accurately in context of this surrounding text: '${surroundingText}'?\n\n`,
1040
+ },
1041
+ },
1042
+ ],
1043
+ temperature: 0.4,
1044
+ maxTokens: 500,
1045
+ }),
1046
+ },
1047
+ })
1048
+ }
1049
+
1050
+ const content = Array.isArray(response.result.content) ? response.result.content : [response.result.content]
1051
+ const evaluation =
1052
+ content
1053
+ .filter(block => block.type === 'text')
1054
+ .map(block => block.text)
1055
+ .join('\n') || 'Unable to generate summary'
1056
+
1057
+ return {
1058
+ content: [
1059
+ {
1033
1060
  type: 'text',
1034
- text: `Does this alt text: '${alt}' meet accessibility guidelines and describe the image: ${image} accurately in context of this surrounding text: '${surroundingText}'?\n\n`,
1061
+ text: evaluation,
1035
1062
  },
1036
- },
1037
- ],
1038
- temperature: 0.4,
1039
- maxTokens: 500,
1040
- })
1041
-
1042
- return {
1043
- content: [
1044
- {
1045
- type: 'text',
1046
- text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary',
1047
- },
1048
- ],
1049
- altTextEvaluation: response.content.type === 'text' ? response.content.text : 'Unable to generate summary',
1050
- nextSteps: `If the evaluation indicates issues with the alt text, provide more meaningful alt text based on the feedback. DO NOT run this tool repeatedly on the same image - evaluations may vary slightly with each run.`,
1051
- }
1052
- },
1053
- )
1054
-
1055
- export {server}
1063
+ ],
1064
+ altTextEvaluation: evaluation,
1065
+ nextSteps: `If the evaluation indicates issues with the alt text, provide more meaningful alt text based on the feedback. DO NOT run this tool repeatedly on the same image - evaluations may vary slightly with each run.`,
1066
+ }
1067
+ },
1068
+ )
1069
+
1070
+ return server
1071
+ }
1072
+
1073
+ const server = createServer()
1074
+
1075
+ export {createServer, server}