Blog
Jan 25, 2026-9 MIN READ
Building an MCP Server for a Vue Design System

Building an MCP Server for a Vue Design System

My AI assistant kept inventing props that did not exist on my components. Better prompts did not fix it. Publishing the component registry as an MCP server did, because it was never a prompting problem.

By Baljeet Singh

I maintain a Vue design system called Empathy DS. Sixty components, each with its own props, variants and slots, published to npm and documented on a site.

For a while, every AI assistant I pointed at it wrote code that looked right and was wrong.

<EInput variant="ghost" />. There is no ghost variant on EInput. size="md" passed to a component that takes dense. A DataTable imported confidently, because most design systems have one and mine does not.

None of it was a syntax error. It was plausible, well-formed, and silently incorrect — the worst kind. You only find out when the styles do not apply.

What I Tried First

The obvious things, in the obvious order.

Better prompts. I described the naming conventions. I explained that variants are semantic, not visual. This worked for about three messages, then the model drifted back.

More examples in context. I pasted component usage into the system prompt. Sixty components will not fit, so I pasted the ten I used most. The model got those ten right and invented harder for the other fifty.

A longer system message. I wrote a document about the design system's philosophy. It made the model more confident, not more correct.

Every attempt was the same idea: if I describe this well enough, it will stop making things up. That idea is wrong, and it took me too long to see why.

The Reframe

The model was not failing to understand my design system. It had never seen my design system. It was doing what it does when it lacks information — producing the most probable-looking answer.

And variant="ghost" is a very probable-looking answer. Shadcn has it. Chakra has something like it. The model is not hallucinating in a vacuum. It's completing a pattern from every other component library it has read, because mine was not available to it.

So the question is not how do I describe my components better. It is why is it guessing at all?

I already had the answer in my repo. I generate a registry.json for the docs site — every component with its name, description, category, props and examples. 112KB of exactly what the model was inventing.

It just had no way to ask for it.

The Fix

I published the registry as an MCP server. Here's the whole thing, in build order. It's small enough to copy.

1. Generate a Registry

You probably already have this data. I emit one JSON file from the monorepo at build time and ship it with the docs:

{
  "version": "1.0.0",
  "packageName": "@empathyds/vue",
  "totalComponents": 60,
  "components": [
    {
      "name": "accordion",
      "description": "A vertically stacked set of interactive headings.",
      "category": "Layout",
      "props": [
        { "name": "modelValue", "type": "string | string[]", "description": "Open item(s)" },
        { "name": "type", "type": "'single' | 'multiple'", "default": "'single'" }
      ],
      "examples": ["<EAccordion type=\"single\" collapsible>…</EAccordion>"],
      "docsUrl": "https://empathyds.com/components/accordion"
    }
  ]
}

Mine is 112KB for 60 components. The important bit is that it is generated, so it cannot drift from the components.

2. Fetch It, With a Cache

The server does not bundle the data. It fetches the deployed file, so it is never stale against the published docs:

const CACHE_TTL = 5 * 60 * 1000;
let registryCache: Registry | null = null;
let registryCacheTime = 0;

async function getRegistry(): Promise<Registry | null> {
  if (registryCache && Date.now() - registryCacheTime < CACHE_TTL) {
    return registryCache;
  }
  try {
    const response = await fetch(mcpConfig.registryUrl);
    if (!response.ok) throw new Error(`Failed to fetch registry: ${response.statusText}`);
    registryCache = await response.json();
    registryCacheTime = Date.now();
    return registryCache;
  } catch (error) {
    console.error('Error fetching remote registry:', error);
    return null;
  }
}

Log to stderr, never stdout. On a stdio transport stdout is the protocol channel, so one stray console.log corrupts the stream and your client drops the server with a useless error. This is the most common way to break an MCP server.

3. Define the Tools

Two dependencies: @modelcontextprotocol/sdk and zod.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({ name: 'empathy-ds', version: '0.0.1' });

A tool with no arguments:

server.tool(
  'getAllComponents',
  'Lists all 60+ Empathy DS Vue components with their names, descriptions, and categories. Use this to discover available components.',
  {},
  async () => {
    const components = await listComponents();
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          totalComponents: components.length,
          packageName: mcpConfig.packageName,
          components,
        }, null, 2),
      }],
    };
  },
);

And one that takes an argument:

server.tool(
  'getComponent',
  'Gets detailed information about a specific component including props, variants, and usage examples. Use this when you need implementation details.',
  {
    componentName: z.string().describe(
      "The kebab-case name of the component (e.g., 'button', 'alert-dialog', 'dropdown-menu')",
    ),
  },
  async ({ componentName }) => {
    const component = await getComponentDetail(componentName);
    if (!component) {
      return {
        content: [{
          type: 'text',
          text: `Component "${componentName}" not found. Use getAllComponents to see available components.`,
        }],
        isError: true,
      };
    }
    return { content: [{ type: 'text', text: JSON.stringify(component, null, 2) }] };
  },
);

Three things in there do more work than they look like they do.

The tool description is a prompt. The model reads it to decide whether to call the tool. "Use this when you need implementation details" is an instruction, not documentation. Write it for someone who will act on it.

.describe() teaches the argument shape. I had to spell out kebab-case with examples. Without that, models passed AlertDialog and got nothing back.

The not-found branch returns a next step. "Use getAllComponents to see available components" means a wrong guess fixes itself in one turn instead of the model inventing something.

4. Start It on stdio

async function startServer() {
  try {
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error('Empathy DS MCP server started');
  } catch (error) {
    console.error('Error starting MCP server:', error);
    process.exit(1);
  }
}
startServer();

5. Make It Runnable With npx

{
  "name": "@empathyds/mcp-server",
  "type": "module",
  "bin": { "empathy-ds-mcp": "dist/server.js" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.23.0"
  }
}

Put #!/usr/bin/env node at the top of the built file. Now anyone can run it without cloning anything:

{
  "mcpServers": {
    "empathy-ds": {
      "command": "npx",
      "args": ["-y", "@empathyds/mcp-server"]
    }
  }
}

The Full Tool List

Eight tools, all deliberately boring:

ToolWhat it answers
getAllComponentsWhat exists?
getComponentWhat props and examples does this one have?
getComponentsByCategoryWhat's available for forms, layout, feedback?
searchComponentsIs there something for this?
getCategoriesHow is this organised?
getBlocksAre there prebuilt patterns?
getBlockHow does this pattern work?
getUsageGuideWhat are the conventions?

There is no cleverness in any of them. Every one is a question the assistant used to answer by guessing.

The hallucinated props mostly stopped. Not because the model got smarter, and not because I wrote a better prompt — because getComponent("input") returns six real props and there is nothing left to invent.

What About a Skill?

Fair question, and the short answer is that I built one of those too — @empathyds/skills, a month after this server — and kept both. A skill carries judgement; a server carries facts that change. I wrote up which one to build separately, because the answer is longer than a paragraph.

The General Shape

The lesson is not really about design systems.

When an agent is confidently wrong, ask what it was forced to guess at, and whether you can turn that guess into a call.

Most of the effort in this space goes into prompting — describing your domain in words and hoping description substitutes for knowledge. Sometimes it does. But when a model is wrong in a confident, plausible, pattern-completing way, that is usually a gap, not a misread instruction. And a gap you can close with a lookup.

It is a retrieval problem wearing a prompting problem's clothes.

Once you start looking for that shape it is everywhere. An agent inventing API endpoints needs your OpenAPI spec, not a better description of your API. An agent misremembering your schema needs to be able to query it. An agent guessing at your component props needs your registry.

The fix is almost always smaller than the prompt you were about to write.

© 2019-2026 Baljeet Singh. All rights reserved.