Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions corpus/frontend/shadcn/button.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Button
category: button
description: Interactive button with variant + size support. Uses CVA for variant composition.
basePrimitive: native <button> element
dataSlots:
- button
variants:
- default
- outline
- secondary
- ghost
- destructive
- link
sizes:
- sm
- md
- lg
- icon
requiresUseClient: false
usageSnippet: |
// Basic usage
<Button>Click me</Button>

// With variants and sizes
<Button variant="outline" size="sm">Small Outline</Button>
<Button variant="destructive" size="lg">Large Destructive</Button>

// Icon usage (data-slot automatic in component)
<Button size="icon">
<PlusIcon />
</Button>
pairsWith:
- Dialog
- Form
- DropdownMenu
33 changes: 33 additions & 0 deletions corpus/frontend/shadcn/dialog.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Dialog
category: dialog
description: Modal dialog with backdrop and portal. Uses @base-ui/react Dialog primitive.
basePrimitive: "@base-ui/react/dialog"
dataSlots:
- dialog-overlay
- dialog-content
- dialog-header
- dialog-title
- dialog-description
- dialog-footer
variants: []
sizes: []
requiresUseClient: true
usageSnippet: |
<Dialog>
<DialogTrigger render={<Button />}>Open Dialog</DialogTrigger>
<DialogContent title="Confirm Action">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<Button variant="destructive">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
pairsWith:
- Button
- Form
- Select
37 changes: 37 additions & 0 deletions corpus/frontend/shadcn/field.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Field
category: form
description: Form field wrapper with label, description, error. Uses @container for responsive layout.
basePrimitive: "@base-ui/react/field"
dataSlots:
- field
- field-label
- field-control
- field-description
- field-error
variants:
- vertical
- horizontal
sizes: []
requiresUseClient: false
usageSnippet: |
<Field orientation="vertical">
<FieldLabel>Username</FieldLabel>
<FieldContent>
<Input placeholder="Enter username" />
<FieldDescription>This is your public display name.</FieldDescription>
<FieldError errors={errors.username} />
</FieldContent>
</Field>

// Horizontal orientation with Container Queries
<FieldGroup className="max-w-md">
<Field orientation="horizontal">
<FieldLabel>Email</FieldLabel>
<Input type="email" />
</Field orientation="horizontal">
</FieldGroup>
pairsWith:
- Input
- Select
- Textarea
- Checkbox
10 changes: 10 additions & 0 deletions corpus/frontend/shadcn/index.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace: frontend.shadcn
components:
button:
file: button.yaml
dialog:
file: dialog.yaml
field:
file: field.yaml
select:
file: select.yaml
33 changes: 33 additions & 0 deletions corpus/frontend/shadcn/select.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Select
category: dropdown
description: Accessible select with search and keyboard nav. Uses @base-ui/react Select.
basePrimitive: "@base-ui/react/select"
dataSlots:
- select-trigger
- select-value
- select-content
- select-item
- select-icon
variants: []
sizes:
- sm
- md
- lg
requiresUseClient: true
usageSnippet: |
<Select defaultValue="apple">
<SelectTrigger>
<SelectValue placeholder="Select fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
pairsWith:
- Field
- Form
2 changes: 2 additions & 0 deletions corpus/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ namespaces:
index: frontend/designer/index.yaml
frontend.design-tokens:
index: frontend/design-tokens/index.yaml
frontend.shadcn:
index: frontend/shadcn/index.yaml
114 changes: 112 additions & 2 deletions src/plugins/shadcn/tools/get-component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,111 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
import { z } from "zod";
import { getComponentByName, SHADCN_COMPONENTS } from "../data.js";
import { getComponentByName, SHADCN_COMPONENTS, type ShadcnComponent } from "../data.js";

type CorpusIndex = {
namespaces?: {
"frontend.shadcn"?: {
index?: string;
};
};
};

type CorpusNamespaceIndex = {
namespace?: string;
components?: Record<string, { file?: string }>;
};

type CorpusComponentEntry = {
name?: string;
category?: string;
description?: string;
basePrimitive?: string;
dataSlots?: string[];
variants?: string[];
sizes?: string[];
requiresUseClient?: boolean;
usageSnippet?: string;
pairsWith?: string[];
};

type LoadedCorpusComponent = { component: ShadcnComponent; source: string };

const moduleDir = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(moduleDir, "../../../../corpus");
const corpusNamespace = "frontend.shadcn";

const cachedCorpusComponents = new Map<string, LoadedCorpusComponent | null>();

function normalize(name: string): string {
return name.toLowerCase().trim();
}

function loadCorpusComponent(name: string): LoadedCorpusComponent | null {
const key = normalize(name);
if (cachedCorpusComponents.has(key)) {
return cachedCorpusComponents.get(key) ?? null;
}

try {
const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8");
const index = YAML.parse(indexRaw) as CorpusIndex | null;
const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index;
if (!namespaceIndexPath) {
cachedCorpusComponents.set(key, null);
return null;
}

const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8");
const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null;
const componentPath = namespaceIndex?.components?.[key]?.file;
if (!componentPath) {
cachedCorpusComponents.set(key, null);
return null;
}

const raw = readFileSync(join(corpusRoot, "frontend/shadcn", componentPath), "utf8");
const entry = YAML.parse(raw) as CorpusComponentEntry | null;
if (
!entry ||
normalize(entry.name ?? "") !== key ||
!entry.category ||
!entry.description ||
!entry.basePrimitive ||
!Array.isArray(entry.dataSlots) ||
!entry.usageSnippet ||
typeof entry.requiresUseClient !== "boolean" ||
!Array.isArray(entry.pairsWith)
) {
cachedCorpusComponents.set(key, null);
return null;
}

const loaded: LoadedCorpusComponent = {
component: {
name: entry.name ?? name,
category: entry.category as ShadcnComponent["category"],
description: entry.description,
basePrimitive: entry.basePrimitive,
dataSlots: entry.dataSlots,
variants: entry.variants,
sizes: entry.sizes,
requiresUseClient: entry.requiresUseClient,
usageSnippet: entry.usageSnippet,
pairsWith: entry.pairsWith,
},
source: corpusNamespace,
};
cachedCorpusComponents.set(key, loaded);
return loaded;
} catch {
cachedCorpusComponents.set(key, null);
return null;
}
}

export function register(server: McpServer): void {
server.tool(
Expand All @@ -10,7 +115,8 @@ export function register(server: McpServer): void {
name: z.string().describe("Component name (e.g., 'Button', 'Dialog', 'Field', 'Select'). Case-insensitive."),
},
async ({ name }) => {
const component = getComponentByName(name);
const corpusEntry = loadCorpusComponent(name);
const component = corpusEntry?.component ?? getComponentByName(name);
if (!component) {
const available = SHADCN_COMPONENTS.map((c) => c.name).join(", ");
return {
Expand Down Expand Up @@ -58,6 +164,10 @@ export function register(server: McpServer): void {
text += `- [ ] Props spread (...props) to underlying primitive\n`;
text += `- [ ] OKLCH color tokens from design system (not hardcoded hex)\n`;

if (corpusEntry) {
text += `\n**Corpus Source:** ${corpusEntry.source}`;
}

return { content: [{ type: "text" as const, text }] };
}
);
Expand Down
41 changes: 41 additions & 0 deletions tests/shadcn-component-corpus-backed-tools-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { captureTool, extractTextContent } from "./helpers";
import { register as registerShadcnGetComponent } from "../src/plugins/shadcn/tools/get-component.ts";

const shadcnGetComponent = captureTool(registerShadcnGetComponent);

test("shadcn_get_component prefers corpus metadata for Button", async () => {
const result = await shadcnGetComponent.invoke({ name: "Button" });
const text = extractTextContent(result);

expect(text).toContain("# Button");
expect(text).toContain("**Corpus Source:** frontend.shadcn");
expect(text).toContain('<Button variant="outline"');
});

test("shadcn_get_component prefers corpus metadata for Dialog", async () => {
const result = await shadcnGetComponent.invoke({ name: "Dialog" });
const text = extractTextContent(result);

expect(text).toContain("# Dialog");
expect(text).toContain("**Corpus Source:** frontend.shadcn");
expect(text).toContain("<DialogTrigger render={<Button />}>");
});

test("shadcn_get_component prefers corpus metadata for Field", async () => {
const result = await shadcnGetComponent.invoke({ name: "Field" });
const text = extractTextContent(result);

expect(text).toContain("# Field");
expect(text).toContain("**Corpus Source:** frontend.shadcn");
expect(text).toContain("<FieldLabel>Username</FieldLabel>");
});

test("shadcn_get_component prefers corpus metadata for Select", async () => {
const result = await shadcnGetComponent.invoke({ name: "Select" });
const text = extractTextContent(result);

expect(text).toContain("# Select");
expect(text).toContain("**Corpus Source:** frontend.shadcn");
expect(text).toContain('<Select defaultValue="apple">');
});
Loading