Files
greyhaven-design-system/mcp/server.ts
2026-04-13 15:33:00 -05:00

305 lines
9.0 KiB
JavaScript

#!/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<string>()
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<string, string[]> = {
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,
),
}],
}
},
)
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
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)