455 lines
14 KiB
JavaScript
455 lines
14 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,
|
|
),
|
|
}],
|
|
}
|
|
},
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, RegExp> = {
|
|
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)
|