@kovaldev/core-mcp
v0.1.0
Published
Koval Core as an MCP server — decide a design system with an agent, then emit global.css and theme-rules.md
Maintainers
Readme
@kovaldev/core-mcp
Koval Core as an MCP server — decide a design system with an agent, then emit
global.css and theme-rules.md.
{
"mcpServers": {
"kovalCore": { "command": "npx", "args": ["-y", "@kovaldev/core-mcp"] }
}
}What it is for
Starting a site for a client and settling its design system in conversation: palette, type, shape language, motion. The builder at core.koval.dev/theme is where a person goes to understand the approach and tune by hand; this is the same system with an agent driving.
The division of labour
The framework supplies the vocabulary and the constraints. The agent supplies the judgement. Serif for a law firm, quiet motion for a clinic — that call is the model's, and a curated list of "approved themes" would only get in the way of it. What the server refuses to do is let a choice land outside the vocabulary, or let six colours be picked by hand and hoped over.
Neither half works alone. An agent with no framework invents tokens from nothing; a framework with no agent is a form to fill in.
So the tools do not answer what should this site look like. They answer what are the choices, what does each one commit you to, is this particular answer valid — and then turn a decided config into files.
Tools
| Tool | What it does |
|---|---|
| describe_system | The framework itself: every axis, its options, and what each commits you to. Call this first — it is the vocabulary the other tools expect |
| propose_theme | Reads a plain-language brief into candidates for every axis, ranked by claimed signal and carrying the when / avoid that justify each. Not a theme — see below |
| generate_palette | Derives six brand roles from one anchor by hue geometry, repairs the ink ladder against real contrast obligations, and reports what it moved and why. Omit the anchor and one is chosen for the mood |
| search_fonts | Narrows the 2,096 Fontsource families to a shortlist, by name, category, or variable-only |
| search_icon_sets | The 193 monotone Iconify collections. Coloured sets are never offered — they cannot inherit currentColor, so a theme would never reach them |
| validate_theme | Accepts or refuses a config, naming the offending field |
| emit_files | Turns a decided config into global.css and theme-rules.md, plus the full install line and the Astro wiring |
emit_files returns text and does not touch the filesystem. The agent already has file
tools and knows where the project root is; a server writing paths it inferred is a worse
failure mode than one that hands back a string.
The score is not a verdict
propose_theme ranks options by how many of the brief's signals each one claims. That is
an ordering for reading, not an answer — and the difference matters.
Give it "law firm, established and trustworthy, but modern" and radius comes back with
sharp and subtle tied. The tie is the useful part: subtle's criterion reads "the
right answer when the brief pulls in two directions — professional but not cold", which is
precisely this brief, and it is not the option a scoring system would have picked for you.
So every candidate arrives with its when and avoid. Read them, choose, and be able to
say why in those terms. A brief the vocabulary cannot read comes back with every option at
zero and says so, rather than ranking them arbitrarily and looking confident.
emit_files then reports coherence notes — combinations that are individually
defensible and jointly odd. Hairline borders with elevation draws every card edge twice;
a lift on hover with no transition snaps rather than lifts. Not validation, since a
designer may want either on purpose, but they are the mistakes that look fine one axis at
a time.
Propose and validate, not whitelist
Font and icon choice is not confined to a curated list. The catalogues split the problem where it wants to be split:
- Mechanically checkable, so the framework does it. Does this family exist, is it variable, which package carries it. Is this icon set monotone, what grid, what licence.
- Aesthetic, so you do it. A serif for a law firm; a thin stroke against sharp corners. Iconify's own style vocabulary is four tags across 236 collections and will never tell you that Phosphor has rounded terminals — a model knows that, and a whitelist would only get in the way.
So propose anything from the whole catalogue; the resolver checks it and refuses with a
reason. emit_files derives the package name and the CSS stack from the catalogue entry,
including the Variable suffix a variable family registers under, which is the one part
of the mapping you would get wrong by hand.
Two things it will not do
Invent colours. generate_palette computes five roles from one anchor and repairs them
against contrast obligations — text-dark/55 on the ground has to clear 4.5:1, and if the
envelope and hue you asked for cannot manage it, the ladder moves and the audit says so.
Six hand-picked hex values carry none of those guarantees, and they are not recoverable
afterwards.
Constrain layout. There is no Container, Stack or Grid primitive to compose with;
page structure stays stock Tailwind utilities in the markup. The conventions fix the
vocabulary, not the composition.
Output
Two files, and only one of them is reading material.
theme-rules.md(~2,700 tokens) goes in the project root, pointed at fromAGENTS.mdso it is in context every session. This is what the agent reads.global.cssreplaces the project's stylesheet. It is a build input — Tailwind compiles it. No agent should ever load it into context.
emit_files also returns setup: the whole npm i line, the astro.config.mjs snippet,
and the root layout. The stylesheet opens with @import "tailwindcss" and
@plugin "@tailwindcss/typography", so a project missing either fails to build — and the
error names the plugin rather than the file that asked for it. Listing every package is
what makes the output a foundation you paste rather than a file you then debug.
Fonts arrive through the Astro Fonts API, not as Fontsource packages. setup.astroConfig
carries a fonts: [...] entry per family and setup.layout the <Font /> tags that go in the
<head>; Astro fetches the faces at build time, self-hosts them, and generates a
metric-matched fallback. So there is nothing to npm i for type, and nothing to remember to
import. A family is asked for only at the weights the scale uses (400–700) and only with
italics if it has them.
theme-rules.md carries the same block, which is the point: the file that names a family is
the file that says how to get it.
Development
npm run start # run from source
npm run build # bundle to dist/ for publishing
node --test ../mcp/test/* # from the repo root: npm testThe server is deliberately thin: every generator it calls lives in src/lib/*.mjs in the
main repo and is pure ESM with zero Node imports, because the same code runs in the browser
inside /theme. The bundle carries those modules rather than depending on a published
version of them — there is no release the server could drift from, because it is built from
the tree it lives in. mcp/test/server.test.mjs asserts the emitted files match the
builder's byte for byte.
