@tabula-css/mcp
Tabula's read-only MCP server: the agent-facing surface over the generated .tabula/ artifacts.
Install
npm install --save-dev @tabula-css/mcpDev dependency — it's a local tool your coding agent's MCP client launches, not a runtime dependency of your app. It bundles @tabula-css/cli, @tabula-css/core, @tabula-css/eslint-plugin, @tabula-css/merge, @tabula-css/registry, and @tabula-css/tokens. @typescript-eslint/parser ships as a regular dependency; eslint is an optional peer, needed only by the validate_source tool.
Overview
Where llms.txt/llms-full.txt give an agent a static map of the vocabulary, the MCP server gives it live, structured access to the same .tabula/ artifacts: resolving what a class string actually renders, previewing a cn() merge before writing it, reverse-looking-up a class by intent instead of recalling a name, and validating a proposed edit against the real registry before it lands. Every tool is read-only — nothing here writes a file; the two proposal tools return a patch for the agent's own edit path to apply, so a human reviews the diff.
Run
npx tabula-mcp [--cwd <dir>]The binary is tabula-mcp (from bin.ts). It speaks MCP over stdio (StdioServerTransport) — stdout carries the protocol and nothing else; every diagnostic goes to stderr, or it would be parsed as a protocol frame. --cwd points it at a project root other than the current directory. If .tabula/ is missing or malformed, it refuses to start (exit 2) rather than serve a profile it never read.
Point your MCP client at it directly, e.g. in an MCP-compatible client's config:
{
"mcpServers": {
"tabula": { "command": "npx", "args": ["tabula-mcp"] }
}
}Every response carries a staleness envelope
{ "profileVersion": "…", "sourceHash": "…", "stale": false }Staleness is recomputed on every call — the server re-stats .tabula/ and the token source and reloads when they've moved, so the edit-then-rebuild loop never needs a restart. Every tool refuses stale reads with:
{
"error": "STALE_REGISTRY",
"message": "The .tabula/ artifacts do not match the current inputs. …",
"command": ["tabula", "build"],
"reasons": ["…"],
"profileVersion": "…", "sourceHash": "…", "stale": true
}Two tools are exempt from the refusal (though they still report stale: true when it applies): doctor, whose whole job is reporting staleness, and explain, which only reads @tabula-css/core's frozen catalog and touches no artifact. When the source-drift check couldn't run (an unresolvable package version, or unreadable project source), the envelope additionally carries "staleCheck": "artifacts-only" — see Staleness and the artifacts-only check.
Tools
resolve_classes
"What does this element actually look like?" The complete local styling model for a class string: base declarations, conditional bands, ambient properties, atomic/unknown classes, declared group dependencies.
| Param | Type | Required |
|---|---|---|
classes | string | string[] | yes |
axes | Record<string, string> (e.g. { theme: "dark" }) | no |
// → resolve_classes({ classes: "bg-surface p-md" })
{ "profileVersion": "…", "sourceHash": "…", "stale": false, /* declarations, bands, … */ }preview_merge
Predicts exactly what cn(...) will produce before you write it: the merged string, plus every dropped class, what shadowed it, and on which CSS property.
| Param | Type | Required |
|---|---|---|
fragments | string[] — in order; the consumer className goes last | yes |
find_class_for
Reverse lookup by intent — the highest-value tool in the set. Matches only real text (class name, family, declared properties/values, token descriptions) with matchedOn reported per hit; no synonym table, no fuzzy score. A miss means no such class exists, not "guess an arbitrary value."
| Param | Type | Required |
|---|---|---|
intent | string | no |
property | string, e.g. "padding-inline" | no |
value | string, e.g. "#ffffff" | no |
family | string, e.g. "bg" | no |
limit | number (default 25) | no |
// → find_class_for({ intent: "raised card background" })
{ "matches": [{ "class": "bg-surface-raised", "family": "bg", "matchedOn": ["intent"], "rank": 30091300002, … }], "total": 1 }get_tokens
Every token (or a namespace/substring slice), straight from tokens.resolved.json — read this before choosing a value.
| Param | Type | Required |
|---|---|---|
namespace | string, e.g. "color" | no |
query | string, substring match over path + description | no |
get_vocabulary
The full class list, paged — never silently truncated; the response reports total/pages/hasMore.
| Param | Type | Required |
|---|---|---|
family | string | no |
prefix | string | no |
page | number, zero-based | no |
pageSize | number (default 100, max 500) | no |
explain_ban
Why a class is banned or unregistered, with its replacement — never a bare "not found." Call it before writing a class you're unsure about (space-x-4, dark:bg-black, pl-4).
| Param | Type | Required |
|---|---|---|
class | string, e.g. "space-x-4" | yes |
resolve_element
Points at a JSX element by { file, line, col } and returns its resolved classes, the text context it inherits from same-file ancestors, and whether each group/peer it references has a marker in that file. Same-file analysis only — at a component boundary it returns status: "unknown" with the next file to open, rather than guessing what the component renders. It also omits axisValues, since which axis combination an element renders under is a fact about the running document, not the source.
| Param | Type | Required |
|---|---|---|
file | string, relative to project root | yes |
line | number, 1-based | yes |
col | number, 1-based | yes |
validate_source
Lints a source string with the real strict ESLint config against your registry — catches what check_classes structurally cannot, because it sees the AST (runtime class construction, className ordering, group/peer structure, inheritance boundaries, exception scope). Inline eslint-disable comments are ignored, so a snippet can't talk its way to a clean verdict. Needs eslint and @typescript-eslint/parser installed; without them it returns LINTER_UNAVAILABLE and the install command.
| Param | Type | Required |
|---|---|---|
code | string — the source text to lint | yes |
filename | string, e.g. "src/ui/card.tsx" — never read from disk; selects rules and exception scopes | yes |
propose_exception
The only legal way to introduce a value with no token. Returns a patch — writes nothing. Validates through the real token validator, nudges toward an existing token when one is within ~5%, and returns the class name the patch would mint.
| Param | Type | Required |
|---|---|---|
name | string, kebab-case | yes |
$type | string (DTCG type) | yes |
value | string, e.g. "347px" | yes |
families | string[], e.g. ["w"] | yes |
reason | string, ≥40 chars | yes |
owner | string, e.g. "@design-systems" | yes |
expires | string, YYYY-MM-DD, ≤12 months (≤90 days if literal) | yes |
allowedIn | string[] (globs) | yes |
description | string | no |
ticket | string | no |
literal | boolean | no |
propose_token
Mint a new design token — prefer this over propose_exception, since a token is permanent vocabulary. Returns a patch — writes nothing. Validated by splicing into the real token document.
| Param | Type | Required |
|---|---|---|
name | string, "<namespace>.<name>" | yes |
$type | string (DTCG type) | yes |
value | string — a single literal; mutually exclusive with cases | no |
cases | object — an axis map, e.g. { $axis: "theme", light: "#fff", dark: "#0b0b0b" }; mutually exclusive with value | no |
description | string | no |
find_group_marker
Where a named group/<name> marker is declared, so a cross-element dependency is a lookup rather than a tree walk. Distinguishes "no such marker" from "this build produced no marker index."
| Param | Type | Required |
|---|---|---|
name | string, e.g. "card" | yes |
check_classes
Pre-flight — call before writing a class attribute. Reports unknown classes (with did-you-mean), banned mechanisms, and same-string slot conflicts, and returns the canonical merged string.
| Param | Type | Required |
|---|---|---|
classes | string[] | yes |
doctor
The health check: staleness (with reasons and fix command), hand-edited artifacts, expiring exceptions, and the escape budget. Answered even while stale — refusing the staleness reporter for being stale would never say what drifted.
No parameters.
// → doctor()
{
"stale": false, "reasons": [], "checkedSource": true,
"drift": [], "expiringExceptions": [], "escapeBudget": { "used": 3, "max": 25 },
"profileVersion": "…", "sourceHash": "…", "manifestInputsHash": "…"
}explain
A TAB-Exxx/TAB-Wxxx code's cause and fix, from the frozen catalog in @tabula-css/core.
| Param | Type | Required |
|---|---|---|
code | string, e.g. "TAB-E113" | yes |
See also
@tabula-css/registry— the artifact every tool reads.@tabula-css/tokens— the token documentpropose_token/propose_exceptionvalidate against.@tabula-css/eslint-plugin— thestrictconfigvalidate_sourceruns.- Agent surface —
llms.txt, the fail-visible guarantee, and the full staleness model. @tabula-css/cli—tabula doctorandtabula explain, this server's CLI counterparts.