import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' // eslint-disable-next-line import/no-namespace import * as cheerio from 'cheerio' // eslint-disable-next-line import/no-namespace import * as z from 'zod' import TurndownService from 'turndown' import {listComponents, listPatterns, listIcons} from './primer' import { listTokenGroups, loadAllTokensWithGuidelines, loadDesignTokensGuide, getDesignTokenSpecsText, getTokenUsagePatternsText, searchTokens, formatBundle, GROUP_ALIASES, tokenMatchesGroup, type TokenWithGuidelines, getValidGroupsList, groupHints, runStylelint, } from './primitives' import packageJson from '../package.json' with {type: 'json'} const server = new McpServer({ name: 'Primer', version: packageJson.version, }) const turndownService = new TurndownService() // Load all tokens with guidelines from primitives const allTokensWithGuidelines: TokenWithGuidelines[] = loadAllTokensWithGuidelines() // ----------------------------------------------------------------------------- // Project setup // ----------------------------------------------------------------------------- server.registerTool( 'init', { description: 'Setup or create a project that includes Primer React', annotations: {readOnlyHint: true}, }, async () => { const url = new URL(`/product/getting-started/react`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `The getting started documentation for Primer React is included below. It's important that the project: - 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 - Installs the latest version of \`@primer/react\` from \`npm\` - Correctly adds the \`ThemeProvider\` and \`BaseStyles\` components to the root of the application - Includes an import to a theme from \`@primer/primitives\` - If the project wants to use icons, also install the \`@primer/octicons-react\` from \`npm\` - Add appropriate agent instructions (like for copilot) to the project to prefer using components, tokens, icons, and more from Primer packages --- ${text} `, }, ], } }, ) // ----------------------------------------------------------------------------- // Components // ----------------------------------------------------------------------------- server.registerTool( 'list_components', {description: 'List all of the components available from Primer React', annotations: {readOnlyHint: true}}, async () => { const components = listComponents().map(component => { return `- ${component.name}` }) return { content: [ { type: 'text', text: `The following components are available in the @primer/react in TypeScript projects: ${components.join('\n')} You can use the \`get_component\` tool to get more information about a specific component. You can use these components from the @primer/react package.`, }, ], } }, ) server.registerTool( 'get_component', { description: 'Retrieve documentation and usage details for a specific React component from the @primer/react package by its name. This tool provides the official Primer documentation for any listed component, making it easy to inspect, reuse, or integrate components in your project.', inputSchema: { name: z.string().describe('The name of the component to retrieve'), }, annotations: {readOnlyHint: true}, }, async ({name}) => { const components = listComponents() const match = components.find(component => { return component.name === name || component.name.toLowerCase() === name.toLowerCase() }) if (!match) { return { isError: true, errorMessage: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`, content: [], } } try { const llmsUrl = new URL(`/product/components/${match.slug}/llms.txt`, 'https://primer.style') const llmsResponse = await fetch(llmsUrl) if (llmsResponse.ok) { const llmsText = await llmsResponse.text() return { content: [ { type: 'text', text: llmsText, }, ], } } } catch (_: unknown) { // If there's an error fetching or processing the llms.txt, we fall back to a generic error message. } return { isError: true, errorMessage: `There was an error fetching documentation for ${name}. Ensure the component exists.`, content: [], } }, ) server.registerTool( 'get_component_examples', { description: 'Get examples for how to use a component from Primer React', inputSchema: { name: z.string().describe('The name of the component to retrieve'), }, annotations: {readOnlyHint: true}, }, async ({name}) => { const components = listComponents() const match = components.find(component => { return component.name === name }) if (!match) { return { content: [ { type: 'text', text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`get_components\` tool.`, }, ], } } const url = new URL(`/product/components/${match.id}`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here are some examples of how to use the \`${name}\` component from the @primer/react package: ${text}`, }, ], } }, ) server.registerTool( 'get_component_usage_guidelines', { description: 'Get usage information for how to use a component from Primer', inputSchema: { name: z.string().describe('The name of the component to retrieve'), }, annotations: {readOnlyHint: true}, }, async ({name}) => { const components = listComponents() const match = components.find(component => { return component.name === name }) if (!match) { return { content: [ { type: 'text', text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`get_components\` tool.`, }, ], } } const url = new URL(`/product/components/${match.id}/guidelines`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) { return { content: [ { type: 'text', text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`, }, ], } } throw new Error(`Failed to fetch ${url}: ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here are the usage guidelines for the \`${name}\` component from the @primer/react package: ${text}`, }, ], } }, ) server.registerTool( 'get_component_accessibility_guidelines', { description: '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.', inputSchema: { name: z.string().describe('The name of the component to retrieve'), }, annotations: {readOnlyHint: true}, }, async ({name}) => { const components = listComponents() const match = components.find(component => { return component.name === name }) if (!match) { return { content: [ { type: 'text', text: `There is no component named \`${name}\` in the @primer/react package. For a full list of components, use the \`list_components\` tool.`, }, ], } } const url = new URL(`/product/components/${match.id}/accessibility`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { if ((response.status >= 400 && response.status < 500) || (response.status >= 300 && response.status < 400)) { return { content: [ { type: 'text', text: `There are no accessibility guidelines for the \`${name}\` component in the @primer/react package.`, }, ], } } throw new Error(`Failed to fetch ${url}: ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here are the accessibility guidelines for the \`${name}\` component from the @primer/react package: ${text}`, }, ], } }, ) // ----------------------------------------------------------------------------- // Patterns // ----------------------------------------------------------------------------- server.registerTool( 'list_patterns', {description: 'List all of the patterns available from Primer React', annotations: {readOnlyHint: true}}, async () => { const patterns = listPatterns().map(pattern => { return `- ${pattern.name}` }) return { content: [ { type: 'text', text: `The following patterns are available in the @primer/react in TypeScript projects: ${patterns.join('\n')}`, }, ], } }, ) server.registerTool( 'get_pattern', { description: 'Get a specific pattern by name', inputSchema: { name: z.string().describe('The name of the pattern to retrieve'), }, annotations: {readOnlyHint: true}, }, async ({name}) => { const patterns = listPatterns() const match = patterns.find(pattern => { return pattern.name === name }) if (!match) { return { content: [ { type: 'text', text: `There is no pattern named \`${name}\` in the @primer/react package. For a full list of patterns, use the \`list_patterns\` tool.`, }, ], } } const url = new URL(`/product/ui-patterns/${match.id}`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url} - ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here are the guidelines for the \`${name}\` pattern for Primer: ${text}`, }, ], } }, ) // ----------------------------------------------------------------------------- // Design Tokens // ----------------------------------------------------------------------------- server.registerTool( 'find_tokens', { description: '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.', inputSchema: { query: z .string() .optional() .default('') .describe('Search keywords (e.g., "danger border", "success background")'), group: z.string().optional().describe('Filter by group (e.g., "fgColor", "border")'), limit: z .number() .int() .min(1) .max(100) .optional() .default(15) .describe('Maximum results to return to stay within context limits'), }, annotations: {readOnlyHint: true}, }, async ({query, group, limit}) => { // Resolve group via aliases const resolvedGroup = group ? GROUP_ALIASES[group.toLowerCase().replace(/\s+/g, '')] || group : undefined // Split query into keywords and extract any that match a known group const rawKeywords = query .toLowerCase() .split(/\s+/) .filter(k => k.length > 0) let effectiveGroup = resolvedGroup const filteredKeywords: string[] = [] for (const kw of rawKeywords) { const normalized = kw.replace(/\s+/g, '') const aliasMatch = GROUP_ALIASES[normalized] if (aliasMatch && !effectiveGroup) { effectiveGroup = aliasMatch } else { filteredKeywords.push(kw) } } // Guard: no query and no group → ask user to provide at least one if (filteredKeywords.length === 0 && !effectiveGroup) { return { content: [ { type: 'text', text: 'Please provide a query, a group, or both. Call `get_design_token_specs` to see available token groups.', }, ], } } // Group-only search: return all tokens in the group const isGroupOnly = filteredKeywords.length === 0 && effectiveGroup let results: TokenWithGuidelines[] if (isGroupOnly) { results = allTokensWithGuidelines.filter(token => tokenMatchesGroup(token, effectiveGroup!)) } else { results = searchTokens(allTokensWithGuidelines, filteredKeywords.join(' '), effectiveGroup) } if (results.length === 0) { const validGroups = getValidGroupsList(allTokensWithGuidelines) return { content: [ { type: 'text', text: `No tokens found matching "${query}"${effectiveGroup ? ` in group "${effectiveGroup}"` : ''}. ### 💡 Available Groups: ${validGroups} ### Troubleshooting for AI: 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. 2. **Property Mismatch**: Do not search for CSS properties like "offset", "padding", or "font-size". Use semantic intent keywords: "danger", "muted", "emphasis". 3. **Typography**: Remember that \`caption\`, \`display\`, and \`code\` groups do NOT support size suffixes. Use the base shorthand only. 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").`, }, ], } } const limitedResults = results.slice(0, limit) let output: string if (!query) { output = `Found ${results.length} token(s). Showing top ${limitedResults.length}:\n\n` } else { output = `Found ${results.length} token(s) matching "${query}". Showing top ${limitedResults.length}:\n\n` } output += formatBundle(limitedResults) if (results.length > limit) { output += `\n\n*...and ${results.length - limit} more matches. Use more specific keywords to narrow the search.*` } return { content: [{type: 'text', text: output}], } }, ) server.registerTool( 'get_token_group_bundle', { description: "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.", inputSchema: { groups: z.array(z.string()).describe('Array of group names (e.g., ["overlay", "shadow", "focus"])'), }, annotations: {readOnlyHint: true}, }, async ({groups}) => { // Normalize and resolve aliases const resolvedGroups = groups.map(g => { const normalized = g.toLowerCase().replace(/\s+/g, '') return GROUP_ALIASES[normalized] || g }) // Filter tokens matching any of the resolved groups const matched = allTokensWithGuidelines.filter(token => resolvedGroups.some(rg => tokenMatchesGroup(token, rg))) if (matched.length === 0) { const validGroups = getValidGroupsList(allTokensWithGuidelines) return { content: [ { type: 'text', text: `No tokens found for groups: ${groups.join(', ')}.\n\n### Valid Groups:\n${validGroups}`, }, ], } } let text = `Found ${matched.length} token(s) across ${resolvedGroups.length} group(s):\n\n${formatBundle(matched)}` const activeHints = resolvedGroups.map(g => groupHints[g]).filter(Boolean) if (activeHints.length > 0) { text += `\n\n### ⚠️ Usage Guidance:\n${activeHints.map(h => `- ${h}`).join('\n')}` } return { content: [{type: 'text', text}], } }, ) server.registerTool( 'get_design_token_specs', { description: 'CRITICAL: CALL THIS FIRST. Provides the logic matrix and the list of valid group names. You cannot search accurately without this map.', annotations: {readOnlyHint: true}, }, async () => { const groups = listTokenGroups() const customRules = getDesignTokenSpecsText(groups) let text: string try { const upstreamGuide = loadDesignTokensGuide() text = `${customRules}\n\n---\n\n${upstreamGuide}` } catch { text = customRules } return { content: [{type: 'text', text}], } }, ) server.registerTool( 'get_token_usage_patterns', { description: '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.', annotations: {readOnlyHint: true}, }, async () => { const customPatterns = getTokenUsagePatternsText() let text: string try { const guide = loadDesignTokensGuide() const goldenExampleMatch = guide.match(/## Golden Example[\s\S]*?(?=\n## |$)/) if (goldenExampleMatch) { text = `${customPatterns}\n\n---\n\n${goldenExampleMatch[0].trim()}` } else { text = customPatterns } } catch { text = customPatterns } return { content: [{type: 'text', text}], } }, ) server.registerTool( 'lint_css', { description: 'REQUIRED FINAL STEP. Use this to validate your CSS. You cannot complete a task involving CSS without a successful run of this tool.', inputSchema: {css: z.string()}, annotations: {readOnlyHint: true}, }, async ({css}) => { try { // --fix flag tells Stylelint to repair what it can const {stdout} = await runStylelint(css) return { content: [ { type: 'text', text: stdout || '✅ Stylelint passed (or was successfully autofixed).', }, ], } } catch (error: unknown) { // If Stylelint still has errors it CANNOT fix, it will land here const errorOutput = error instanceof Error && 'stdout' in error ? (error as Error & {stdout: string}).stdout : String(error) return { content: [ { type: 'text', text: `❌ Errors without autofix remaining:\n${errorOutput}`, }, ], } } }, ) // ----------------------------------------------------------------------------- // Foundations // ----------------------------------------------------------------------------- server.registerTool( 'get_color_usage', {description: 'Get the guidelines for how to apply color to a user interface', annotations: {readOnlyHint: true}}, async () => { const url = new URL(`/product/getting-started/foundations/color-usage`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url} - ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here is the documentation for color usage in Primer:\n\n${text}`, }, ], } }, ) server.registerTool( 'get_typography_usage', { description: 'Get the guidelines for how to apply typography to a user interface', annotations: {readOnlyHint: true}, }, async () => { const url = new URL(`/product/getting-started/foundations/typography`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url} - ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here is the documentation for typography usage in Primer:\n\n${text}`, }, ], } }, ) // ----------------------------------------------------------------------------- // Icons // ----------------------------------------------------------------------------- server.registerTool( 'list_icons', { description: 'List all of the icons (octicons) available from Primer Octicons React', annotations: {readOnlyHint: true}, }, async () => { const icons = listIcons().map(icon => { const keywords = icon.keywords.map(keyword => { return `${keyword}` }) const sizes = icon.heights.map(height => { return `` }) return [``, ...keywords, ...sizes, ``].join('\n') }) return { content: [ { type: 'text', text: `The following icons are available in the @primer/octicons-react package in TypeScript projects: ${icons.join('\n')} 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.`, }, ], } }, ) server.registerTool( 'get_icon', { description: 'Get a specific icon (octicon) by name from Primer', inputSchema: { name: z.string().describe('The name of the icon to retrieve'), size: z.string().optional().describe('The size of the icon to retrieve, e.g. "16"').default('16'), }, annotations: {readOnlyHint: true}, }, async ({name, size}) => { const icons = listIcons() const match = icons.find(icon => { return icon.name === name || icon.name.toLowerCase() === name.toLowerCase() }) if (!match) { return { content: [ { type: 'text', text: `There is no icon named \`${name}\` in the @primer/octicons-react package. For a full list of icons, use the \`get_icon\` tool.`, }, ], } } const url = new URL(`/octicons/icon/${match.name}-${size}`, 'https://primer.style') const response = await fetch(url) if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.statusText}`) } const html = await response.text() if (!html) { return { content: [], } } const $ = cheerio.load(html) const source = $('main').html() if (!source) { return { content: [], } } const text = turndownService.turndown(source) return { content: [ { type: 'text', text: `Here is the documentation for the \`${name}\` icon at size: \`${size}\`: ${text}`, }, ], } }, ) // ----------------------------------------------------------------------------- // Coding guidelines // ----------------------------------------------------------------------------- server.registerTool( 'primer_coding_guidelines', { description: 'Get the guidelines when writing code that uses Primer or for UI code that you are creating', annotations: {readOnlyHint: true}, }, async () => { return { content: [ { type: 'text', text: `When writing code that uses Primer, follow these guidelines: ## Design Tokens - Prefer design tokens over hard-coded values. For example, use \`var(--fgColor-default)\` instead of \`#24292f\`. Use the \`find_tokens\` tool to search for a design token by keyword or group. Use \`get_design_token_specs\` to browse available token groups, and \`get_token_group_bundle\` to retrieve all tokens within a specific group. - Prefer recommending design tokens in the same group for related CSS properties. For example, when styling background and border color, use tokens from the same group/category ## Authoring & Using Components - Prefer re-using a component from Primer when possible over writing a new component. - Prefer using existing props for a component for styling instead of adding styling to a component - Prefer using icons from Primer instead of creating new icons. Use the \`list_icons\` tool to find the icon you need. - Follow patterns from Primer when creating new components. Use the \`list_patterns\` tool to find the pattern you need, if one exists - When using a component from Primer, make sure to follow the component's usage and accessibility guidelines ## Coding guidelines The following list of coding guidelines must be followed: - Do not use the sx prop for styling components. Instead, use CSS Modules. - Do not use the Box component for styling components. Instead, use CSS Modules. `, }, ], } }, ) // ----------------------------------------------------------------------------- // Accessibility // ----------------------------------------------------------------------------- /** * The `review_alt_text` tool is experimental and may be removed in future versions. * * The intent of this tool is to assist products like Copilot Code Review and Copilot Coding Agent * in reviewing both user- and AI-generated alt text for images, ensuring compliance with accessibility guidelines. * This tool is not intended to replace human-generated alt text; rather, it supports the review process * by providing suggestions for improvement. It should be used alongside human review, not as a substitute. * * **/ server.registerTool( 'review_alt_text', { description: 'Evaluates image alt text against accessibility best practices and context relevance.', inputSchema: { surroundingText: z.string().describe('Text surrounding the image, relevant to the image.'), alt: z.string().describe('The alt text of the image being evaluated'), image: z.string().describe('The image URL or file path being evaluated'), }, annotations: {readOnlyHint: true}, }, async ({surroundingText, alt, image}) => { // Call the LLM through MCP sampling const response = await server.server.createMessage({ messages: [ { role: 'user', content: { type: 'text', text: `Does this alt text: '${alt}' meet accessibility guidelines and describe the image: ${image} accurately in context of this surrounding text: '${surroundingText}'?\n\n`, }, }, ], sampling: {temperature: 0.4}, maxTokens: 500, }) return { content: [ { type: 'text', text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary', }, ], altTextEvaluation: response.content.type === 'text' ? response.content.text : 'Unable to generate summary', 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.`, } }, ) export {server}