#!/usr/bin/env node /** * Greyhaven Design System MCP Server * * Provides programmatic access to design tokens, component specs, and * validation tools for any MCP-compatible AI agent. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import * as fs from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' import { COMPONENT_CATALOG, getTokens, type ComponentSpec, } from '../lib/catalog.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const ROOT = path.resolve(__dirname, '..') // --------------------------------------------------------------------------- // Server setup // --------------------------------------------------------------------------- const server = new McpServer({ name: 'greyhaven-design-system', version: '1.0.0', }) // Tool: get_tokens server.tool( 'get_tokens', 'Returns design token values. Optionally filter by category: color, typography, spacing, radii, shadows, motion.', { category: z.string().optional().describe('Token category to filter by') }, async ({ category }) => { const tokens = getTokens(ROOT, category) return { content: [ { type: 'text' as const, text: JSON.stringify(tokens, null, 2), }, ], } }, ) // Tool: get_component server.tool( 'get_component', 'Returns the full spec for a named component: props, variants, usage example, and when to use it.', { name: z.string().describe('Component name (case-insensitive)') }, async ({ name }) => { const component = COMPONENT_CATALOG.find( (c) => c.name.toLowerCase() === name.toLowerCase(), ) if (!component) { return { content: [ { type: 'text' as const, text: `Component "${name}" not found. Use list_components to see available components.`, }, ], isError: true, } } let source = '' try { source = fs.readFileSync(path.join(ROOT, component.file), 'utf-8') } catch { source = '(source file not readable)' } return { content: [ { type: 'text' as const, text: JSON.stringify({ ...component, source }, null, 2), }, ], } }, ) // Tool: list_components server.tool( 'list_components', 'Lists all available design system components, optionally filtered by category.', { category: z .string() .optional() .describe( 'Category filter: primitives, layout, overlay, navigation, data, feedback, form, composition', ), }, async ({ category }) => { let components = COMPONENT_CATALOG if (category) { components = components.filter( (c) => c.category.toLowerCase() === category.toLowerCase(), ) } const summary = components.map((c) => ({ name: c.name, category: c.category, file: c.file, description: c.description, })) return { content: [ { type: 'text' as const, text: JSON.stringify(summary, null, 2), }, ], } }, ) // Tool: validate_colors server.tool( 'validate_colors', 'Checks if a code snippet uses valid design system colors. Returns warnings for raw hex values that should use tokens instead.', { code: z.string().describe('Code string to validate') }, async ({ code }) => { const hexRegex = /#[0-9a-fA-F]{3,8}/g const matches = code.match(hexRegex) || [] const validHexValues = new Set([ '#f9f9f7', '#F9F9F7', '#161614', '#d95e2a', '#D95E2A', '#b43232', '#B43232', '#f0f0ec', '#F0F0EC', '#ddddd7', '#DDDDD7', '#c4c4bd', '#C4C4BD', '#a6a69f', '#A6A69F', '#7f7f79', '#7F7F79', '#575753', '#2f2f2c', '#2F2F2C', '#fff', '#FFF', '#ffffff', '#FFFFFF', '#000', '#000000', ]) const warnings: string[] = [] const seen = new Set() for (const hex of matches) { const lower = hex.toLowerCase() if (seen.has(lower)) continue seen.add(lower) if (validHexValues.has(hex)) { warnings.push( `${hex} -- valid Greyhaven primitive, but prefer semantic CSS variables (e.g., var(--primary)).`, ) } else { warnings.push( `${hex} -- NOT a Greyhaven design token. Use semantic tokens: bg-primary, text-foreground, border-border, etc.`, ) } } if (warnings.length === 0) { return { content: [{ type: 'text' as const, text: 'No raw hex colors found. The code uses design system tokens correctly.', }], } } return { content: [{ type: 'text' as const, text: `Found ${matches.length} hex color(s):\n\n${warnings.join('\n')}`, }], } }, ) // Tool: suggest_component server.tool( 'suggest_component', 'Suggests the best Greyhaven component(s) for a described UI need.', { description: z.string().describe('Natural language description of what UI you need') }, async ({ description }) => { const desc = description.toLowerCase() const suggestions: ComponentSpec[] = [] const keywords: Record = { button: ['button', 'click', 'action', 'submit', 'cta'], card: ['card', 'container', 'box', 'panel', 'tile'], dialog: ['dialog', 'modal', 'popup', 'overlay', 'confirm'], input: ['input', 'text field', 'textbox', 'form field'], table: ['table', 'grid', 'data', 'list', 'rows', 'columns'], navbar: ['navbar', 'navigation bar', 'header', 'top bar', 'nav'], hero: ['hero', 'banner', 'landing', 'splash', 'headline'], 'cta-section': ['cta', 'call to action', 'signup', 'sign up'], footer: ['footer', 'bottom', 'copyright'], form: ['form', 'registration', 'login', 'sign in', 'contact'], select: ['select', 'dropdown', 'choose', 'pick'], tabs: ['tabs', 'tab', 'sections', 'switch between'], accordion: ['accordion', 'expandable', 'collapsible', 'faq'], alert: ['alert', 'warning', 'error', 'notification', 'message'], badge: ['badge', 'tag', 'label', 'status', 'chip'], avatar: ['avatar', 'profile', 'user image', 'photo'], tooltip: ['tooltip', 'hint', 'hover info'], progress: ['progress', 'loading', 'bar', 'percentage'], skeleton: ['skeleton', 'loading', 'placeholder', 'shimmer'], drawer: ['drawer', 'sheet', 'bottom sheet', 'slide'], popover: ['popover', 'floating', 'popup content'], separator: ['separator', 'divider', 'line', 'hr'], breadcrumb: ['breadcrumb', 'trail', 'path'], pagination: ['pagination', 'pages', 'next', 'previous'], section: ['section', 'content block', 'area'], 'page-layout': ['layout', 'page', 'shell', 'scaffold', 'template'], logo: ['logo', 'brand', 'greyhaven'], calendar: ['calendar', 'date', 'date picker'], } for (const [componentName, kw] of Object.entries(keywords)) { if (kw.some((k) => desc.includes(k))) { const match = COMPONENT_CATALOG.find( (c) => c.name.toLowerCase() === componentName.toLowerCase() || c.name.toLowerCase().replace(/\s+/g, '-') === componentName, ) if (match) suggestions.push(match) } } if (suggestions.length === 0) { return { content: [{ type: 'text' as const, text: `No strong match for "${description}". Use list_components() to browse all ${COMPONENT_CATALOG.length} components.`, }], } } return { content: [{ type: 'text' as const, text: JSON.stringify( suggestions.map((s) => ({ name: s.name, category: s.category, description: s.description, example: s.example, })), null, 2, ), }], } }, ) // --------------------------------------------------------------------------- // Brand tools & resources // --------------------------------------------------------------------------- const BRAND_SKILL_PATH = path.join(ROOT, 'skill', 'BRAND.md') function readBrandSkill(): string { try { return fs.readFileSync(BRAND_SKILL_PATH, 'utf-8') } catch { return '(skill/BRAND.md not found — hand-curated brand skill is missing)' } } server.tool( 'get_brand_rules', 'Returns the Greyhaven brand voice, tone, and messaging rules. Use this BEFORE generating any user-facing marketing copy, CTAs, landing page content, or product explanations. Covers positioning, brand axes, tone, writing rules, reasoning patterns, CTA guidance, logo usage, and a self-check list.', { section: z .enum([ 'all', 'positioning', 'axes', 'tone', 'writing-rules', 'reasoning-patterns', 'cta', 'logo', 'self-check', ]) .optional() .describe('Optional section filter. Default returns the full brand skill.'), }, async ({ section }) => { const full = readBrandSkill() if (!section || section === 'all') { return { content: [{ type: 'text' as const, text: full }], } } // Section anchors in BRAND.md (markdown H2 headings) const anchors: Record = { positioning: /## 2\. Core Positioning[\s\S]*?(?=\n## |\n---)/, axes: /## 3\. The Three Brand Axes[\s\S]*?(?=\n## |\n---)/, tone: /## 4\. Tone of Voice[\s\S]*?(?=\n## |\n---)/, 'writing-rules': /## 5\. Writing Rules[\s\S]*?(?=\n## |\n---)/, 'reasoning-patterns': /## 6\. Patterns for Reasoning[\s\S]*?(?=\n## |\n---)/, cta: /## 7\. CTA Guidance[\s\S]*?(?=\n## |\n---)/, logo: /## 10\. Logo Usage[\s\S]*?(?=\n## |\n---)/, 'self-check': /## 11\. Self-check[\s\S]*?(?=\n## |\n---)/, } const re = anchors[section] const match = re ? full.match(re) : null if (!match) { return { content: [{ type: 'text' as const, text: `Section "${section}" not found. Returning full brand skill instead.\n\n${full}`, }], } } return { content: [{ type: 'text' as const, text: match[0] }], } }, ) server.tool( 'validate_copy', 'Checks a piece of user-facing copy against Greyhaven brand rules. Flags hype words, sales language, vague superlatives, and other brand violations. Use on marketing copy, CTAs, headlines, product descriptions before shipping.', { text: z.string().describe('The copy to validate') }, async ({ text }) => { const lower = text.toLowerCase() const bannedWords = [ 'unleash', 'transform', 'revolutionary', 'revolutionize', 'seamless', 'seamlessly', 'game-changing', 'cutting-edge', 'next-gen', 'next-generation', 'leverage', 'synergy', 'unlock', 'supercharge', 'empower', 'empowered', 'unprecedented', 'best-in-class', 'industry-leading', 'world-class', 'lightning-fast', 'blazing fast', ] const vagueSuperlatives = [ 'amazing', 'incredible', 'awesome', 'stunning', 'beautiful', 'powerful', 'robust', 'cutting edge', 'state-of-the-art', ] const urgencyPhrases = [ 'limited time', 'act now', "don't miss out", 'hurry', 'last chance', 'today only', ] const findings: string[] = [] for (const w of bannedWords) { if (lower.includes(w)) { findings.push(`⚠ Banned hype/sales word: "${w}"`) } } for (const w of vagueSuperlatives) { if (lower.includes(w)) { findings.push(`⚠ Vague superlative: "${w}" — replace with specifics`) } } for (const p of urgencyPhrases) { if (lower.includes(p)) { findings.push(`⚠ Urgency framing: "${p}" — Greyhaven does not use urgency`) } } // Exclamation marks const exclamations = (text.match(/!/g) || []).length if (exclamations > 0) { findings.push(`⚠ Found ${exclamations} exclamation mark(s) — Greyhaven copy does not use them`) } if (findings.length === 0) { return { content: [{ type: 'text' as const, text: 'No obvious brand violations found. Still run the self-check list from get_brand_rules({section: "self-check"}) before shipping.', }], } } return { content: [{ type: 'text' as const, text: `Found ${findings.length} potential brand violation(s):\n\n${findings.join('\n')}\n\nFor detailed guidance, call get_brand_rules() or get_brand_rules({section: "tone"}).`, }], } }, ) // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- server.resource('brand://guidelines', 'brand://guidelines', async (uri) => { return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: readBrandSkill(), }], } }) server.resource('tokens://all', 'tokens://all', async (uri) => { const tokens = getTokens(ROOT) return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(tokens, null, 2), }], } }) for (const component of COMPONENT_CATALOG) { const uri = `component://${component.name.toLowerCase()}` server.resource(uri, uri, async (resourceUri) => { let source = '' try { source = fs.readFileSync(path.join(ROOT, component.file), 'utf-8') } catch { source = '(source not readable)' } return { contents: [{ uri: resourceUri.href, mimeType: 'application/json', text: JSON.stringify({ ...component, source }, null, 2), }], } }) } // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- async function main() { const transport = new StdioServerTransport() await server.connect(transport) } main().catch(console.error)