From 856a4207a7004a4a45f6f898d87aa54d469bf256 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Apr 2026 07:17:37 -0400 Subject: [PATCH 1/2] Add MCP server package for Ghost UI registry Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ghost-mcp/package.json | 26 ++++ packages/ghost-mcp/scripts/copy-assets.mjs | 18 +++ packages/ghost-mcp/src/bin.ts | 7 + packages/ghost-mcp/src/data.ts | 150 +++++++++++++++++++++ packages/ghost-mcp/src/index.ts | 1 + packages/ghost-mcp/src/resources.ts | 50 +++++++ packages/ghost-mcp/src/server.ts | 15 +++ packages/ghost-mcp/src/tools.ts | 129 ++++++++++++++++++ packages/ghost-mcp/tsconfig.json | 9 ++ pnpm-lock.yaml | 9 ++ tsconfig.json | 3 +- 11 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 packages/ghost-mcp/package.json create mode 100644 packages/ghost-mcp/scripts/copy-assets.mjs create mode 100644 packages/ghost-mcp/src/bin.ts create mode 100644 packages/ghost-mcp/src/data.ts create mode 100644 packages/ghost-mcp/src/index.ts create mode 100644 packages/ghost-mcp/src/resources.ts create mode 100644 packages/ghost-mcp/src/server.ts create mode 100644 packages/ghost-mcp/src/tools.ts create mode 100644 packages/ghost-mcp/tsconfig.json diff --git a/packages/ghost-mcp/package.json b/packages/ghost-mcp/package.json new file mode 100644 index 0000000..c30e6fb --- /dev/null +++ b/packages/ghost-mcp/package.json @@ -0,0 +1,26 @@ +{ + "name": "@ghost/mcp", + "version": "0.1.0", + "description": "MCP server for the Ghost UI component registry", + "license": "Apache-2.0", + "type": "module", + "bin": { + "ghost-mcp": "./dist/bin.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc --build && node scripts/copy-assets.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.0" + } +} diff --git a/packages/ghost-mcp/scripts/copy-assets.mjs b/packages/ghost-mcp/scripts/copy-assets.mjs new file mode 100644 index 0000000..41df1e9 --- /dev/null +++ b/packages/ghost-mcp/scripts/copy-assets.mjs @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const assetsDir = path.resolve(__dirname, "../dist/assets"); + +fs.mkdirSync(assetsDir, { recursive: true }); + +fs.copyFileSync( + path.resolve(__dirname, "../../ghost-ui/registry.json"), + path.join(assetsDir, "registry.json"), +); + +fs.copyFileSync( + path.resolve(__dirname, "../../ghost-ui/.shadcn/skills.md"), + path.join(assetsDir, "skills.md"), +); diff --git a/packages/ghost-mcp/src/bin.ts b/packages/ghost-mcp/src/bin.ts new file mode 100644 index 0000000..c20c22c --- /dev/null +++ b/packages/ghost-mcp/src/bin.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +const server = createServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/ghost-mcp/src/data.ts b/packages/ghost-mcp/src/data.ts new file mode 100644 index 0000000..2c687c1 --- /dev/null +++ b/packages/ghost-mcp/src/data.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const assetsDir = path.resolve(__dirname, "assets"); + +export interface RegistryFile { + type: string; + target: string; + path: string; +} + +export interface RegistryCssVars { + theme?: Record; + light?: Record; + dark?: Record; +} + +export interface RegistryItem { + name: string; + type: string; + title?: string; + description?: string; + author?: string; + style?: string; + iconLibrary?: string; + baseColor?: string; + dependencies?: string[]; + devDependencies?: string[]; + registryDependencies?: string[]; + files: RegistryFile[]; + categories?: string[]; + cssVars?: RegistryCssVars; +} + +export interface Registry { + $schema: string; + name: string; + homepage: string; + items: RegistryItem[]; +} + +const registryRaw = fs.readFileSync( + path.join(assetsDir, "registry.json"), + "utf-8", +); +const registry: Registry = JSON.parse(registryRaw); + +const skillsContent = fs.readFileSync( + path.join(assetsDir, "skills.md"), + "utf-8", +); + +const itemsByName = new Map(); +for (const item of registry.items) { + itemsByName.set(item.name, item); +} + +export interface ItemSummary { + name: string; + type: string; + categories: string[]; + dependencies: string[]; + registryDependencies: string[]; + description?: string; +} + +export function searchItems( + query?: string, + category?: string, + aiOnly?: boolean, +): ItemSummary[] { + let items = registry.items; + + if (category) { + const lower = category.toLowerCase(); + items = items.filter((i) => + (i.categories ?? []).some((c) => c.toLowerCase() === lower), + ); + } + + if (aiOnly) { + items = items.filter((i) => (i.categories ?? []).includes("ai")); + } + + if (query) { + const lower = query.toLowerCase(); + items = items.filter((i) => i.name.toLowerCase().includes(lower)); + } + + return items.map((i) => ({ + name: i.name, + type: i.type, + categories: i.categories ?? [], + dependencies: i.dependencies ?? [], + registryDependencies: i.registryDependencies ?? [], + description: i.description, + })); +} + +export function getRegistryItem(name: string): RegistryItem | undefined { + return itemsByName.get(name); +} + +export function getCategoriesWithCounts(): Record< + string, + { name: string; count: number } +> { + const result: Record = {}; + for (const item of registry.items) { + for (const cat of item.categories ?? []) { + if (result[cat]) { + result[cat].count++; + } else { + result[cat] = { name: cat, count: 1 }; + } + } + } + return result; +} + +export function getThemePreset( + name: string, +): RegistryCssVars | undefined { + const item = registry.items.find( + (i) => i.type === "registry:theme" && i.name === name, + ); + return item?.cssVars; +} + +export function getComponentSource(name: string): string | null { + const item = itemsByName.get(name); + if (!item || item.files.length === 0) return null; + + const filePath = item.files[0].path; + const ghostUiDir = path.resolve(__dirname, "../../ghost-ui"); + const fullPath = path.join(ghostUiDir, filePath); + + try { + return fs.readFileSync(fullPath, "utf-8"); + } catch { + return null; + } +} + +export function getSkills(): string { + return skillsContent; +} diff --git a/packages/ghost-mcp/src/index.ts b/packages/ghost-mcp/src/index.ts new file mode 100644 index 0000000..0771695 --- /dev/null +++ b/packages/ghost-mcp/src/index.ts @@ -0,0 +1 @@ +export { createServer } from "./server.js"; diff --git a/packages/ghost-mcp/src/resources.ts b/packages/ghost-mcp/src/resources.ts new file mode 100644 index 0000000..3e5834c --- /dev/null +++ b/packages/ghost-mcp/src/resources.ts @@ -0,0 +1,50 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getSkills } from "./data.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const assetsDir = path.resolve(__dirname, "assets"); + +export function registerResources(server: McpServer): void { + server.resource( + "Ghost UI Registry", + "ghost://registry", + { mimeType: "application/json" }, + async () => { + const content = fs.readFileSync( + path.join(assetsDir, "registry.json"), + "utf-8", + ); + return { + contents: [ + { + uri: "ghost://registry", + mimeType: "application/json", + text: content, + }, + ], + }; + }, + ); + + server.resource( + "Ghost UI Skills", + "ghost://skills", + { mimeType: "text/markdown" }, + async () => { + const content = getSkills(); + return { + contents: [ + { + uri: "ghost://skills", + mimeType: "text/markdown", + text: content, + }, + ], + }; + }, + ); +} diff --git a/packages/ghost-mcp/src/server.ts b/packages/ghost-mcp/src/server.ts new file mode 100644 index 0000000..b86bd0f --- /dev/null +++ b/packages/ghost-mcp/src/server.ts @@ -0,0 +1,15 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerTools } from "./tools.js"; +import { registerResources } from "./resources.js"; + +export function createServer(): McpServer { + const server = new McpServer({ + name: "ghost-ui", + version: "0.1.0", + }); + + registerTools(server); + registerResources(server); + + return server; +} diff --git a/packages/ghost-mcp/src/tools.ts b/packages/ghost-mcp/src/tools.ts new file mode 100644 index 0000000..51b4e25 --- /dev/null +++ b/packages/ghost-mcp/src/tools.ts @@ -0,0 +1,129 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + searchItems, + getRegistryItem, + getComponentSource, + getCategoriesWithCounts, + getThemePreset, +} from "./data.js"; + +const REGISTRY_URL = + "https://block.github.io/ghost/r/registry.json"; + +export function registerTools(server: McpServer): void { + server.tool( + "search_components", + "Search Ghost UI components by name, category, or AI filter", + { + query: z.string().optional().describe("Substring to match against component names"), + category: z.string().optional().describe("Filter by category (e.g. input, layout, ai)"), + aiOnly: z.boolean().optional().describe("Only return AI-category components"), + }, + async ({ query, category, aiOnly }) => { + const results = searchItems(query, category, aiOnly); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(results, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_component", + "Get full metadata and source code for a Ghost UI component", + { + name: z.string().describe("Component name from the registry"), + }, + async ({ name }) => { + const item = getRegistryItem(name); + if (!item) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ error: `Component "${name}" not found` }), + }, + ], + }; + } + const source = getComponentSource(name); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ ...item, source }, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_install_command", + "Generate a shadcn install command for Ghost UI components", + { + components: z + .array(z.string()) + .describe("Array of component names to install"), + }, + async ({ components }) => { + const cmd = `npx shadcn@latest add --registry ${REGISTRY_URL} ${components.join(" ")}`; + return { + content: [{ type: "text" as const, text: cmd }], + }; + }, + ); + + server.tool( + "list_categories", + "List all Ghost UI component categories with counts", + {}, + async () => { + const categories = getCategoriesWithCounts(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(categories, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "get_theme", + "Get CSS variables for a Ghost UI theme preset", + { + name: z.string().describe("Theme preset name"), + }, + async ({ name }) => { + const cssVars = getThemePreset(name); + if (!cssVars) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: `Theme "${name}" not found`, + }), + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(cssVars, null, 2), + }, + ], + }; + }, + ); +} diff --git a/packages/ghost-mcp/tsconfig.json b/packages/ghost-mcp/tsconfig.json new file mode 100644 index 0000000..f724352 --- /dev/null +++ b/packages/ghost-mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 107cd76..3f5c781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,15 @@ importers: specifier: ^1.50.0 version: 1.59.1 + packages/ghost-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) + zod: + specifier: ^3.25.0 + version: 3.25.76 + packages/ghost-ui: dependencies: '@hookform/resolvers': diff --git a/tsconfig.json b/tsconfig.json index f3e398f..3cc3704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "files": [], "references": [ { "path": "packages/ghost-core" }, - { "path": "packages/ghost-cli" } + { "path": "packages/ghost-cli" }, + { "path": "packages/ghost-mcp" } ] } From f28168b8f7d63803855b935dfe3cd2ee12a76c31 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Apr 2026 08:38:16 -0400 Subject: [PATCH 2/2] Fix Biome lint: formatting in ghost-mcp package Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ghost-mcp/package.json | 4 +++- packages/ghost-mcp/src/data.ts | 4 +--- packages/ghost-mcp/src/resources.ts | 2 +- packages/ghost-mcp/src/server.ts | 2 +- packages/ghost-mcp/src/tools.ts | 24 ++++++++++++++++-------- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/ghost-mcp/package.json b/packages/ghost-mcp/package.json index c30e6fb..485818c 100644 --- a/packages/ghost-mcp/package.json +++ b/packages/ghost-mcp/package.json @@ -15,7 +15,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc --build && node scripts/copy-assets.mjs" }, diff --git a/packages/ghost-mcp/src/data.ts b/packages/ghost-mcp/src/data.ts index 2c687c1..e34a4dd 100644 --- a/packages/ghost-mcp/src/data.ts +++ b/packages/ghost-mcp/src/data.ts @@ -121,9 +121,7 @@ export function getCategoriesWithCounts(): Record< return result; } -export function getThemePreset( - name: string, -): RegistryCssVars | undefined { +export function getThemePreset(name: string): RegistryCssVars | undefined { const item = registry.items.find( (i) => i.type === "registry:theme" && i.name === name, ); diff --git a/packages/ghost-mcp/src/resources.ts b/packages/ghost-mcp/src/resources.ts index 3e5834c..fae534b 100644 --- a/packages/ghost-mcp/src/resources.ts +++ b/packages/ghost-mcp/src/resources.ts @@ -1,7 +1,7 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getSkills } from "./data.js"; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/ghost-mcp/src/server.ts b/packages/ghost-mcp/src/server.ts index b86bd0f..72438f1 100644 --- a/packages/ghost-mcp/src/server.ts +++ b/packages/ghost-mcp/src/server.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerTools } from "./tools.js"; import { registerResources } from "./resources.js"; +import { registerTools } from "./tools.js"; export function createServer(): McpServer { const server = new McpServer({ diff --git a/packages/ghost-mcp/src/tools.ts b/packages/ghost-mcp/src/tools.ts index 51b4e25..f070436 100644 --- a/packages/ghost-mcp/src/tools.ts +++ b/packages/ghost-mcp/src/tools.ts @@ -1,24 +1,32 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { - searchItems, - getRegistryItem, - getComponentSource, getCategoriesWithCounts, + getComponentSource, + getRegistryItem, getThemePreset, + searchItems, } from "./data.js"; -const REGISTRY_URL = - "https://block.github.io/ghost/r/registry.json"; +const REGISTRY_URL = "https://block.github.io/ghost/r/registry.json"; export function registerTools(server: McpServer): void { server.tool( "search_components", "Search Ghost UI components by name, category, or AI filter", { - query: z.string().optional().describe("Substring to match against component names"), - category: z.string().optional().describe("Filter by category (e.g. input, layout, ai)"), - aiOnly: z.boolean().optional().describe("Only return AI-category components"), + query: z + .string() + .optional() + .describe("Substring to match against component names"), + category: z + .string() + .optional() + .describe("Filter by category (e.g. input, layout, ai)"), + aiOnly: z + .boolean() + .optional() + .describe("Only return AI-category components"), }, async ({ query, category, aiOnly }) => { const results = searchItems(query, category, aiOnly);