Skip to content

@tabula-css/mcp

Tabula's read-only MCP server: the agent-facing surface over the generated .tabula/ artifacts.

Install

bash
npm install --save-dev @tabula-css/mcp

Dev 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

bash
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:

json
{
  "mcpServers": {
    "tabula": { "command": "npx", "args": ["tabula-mcp"] }
  }
}

Every response carries a staleness envelope

json
{ "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:

json
{
  "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.

ParamTypeRequired
classesstring | string[]yes
axesRecord<string, string> (e.g. { theme: "dark" })no
jsonc
// → 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.

ParamTypeRequired
fragmentsstring[] — in order; the consumer className goes lastyes

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."

ParamTypeRequired
intentstringno
propertystring, e.g. "padding-inline"no
valuestring, e.g. "#ffffff"no
familystring, e.g. "bg"no
limitnumber (default 25)no
jsonc
// → 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.

ParamTypeRequired
namespacestring, e.g. "color"no
querystring, substring match over path + descriptionno

get_vocabulary

The full class list, paged — never silently truncated; the response reports total/pages/hasMore.

ParamTypeRequired
familystringno
prefixstringno
pagenumber, zero-basedno
pageSizenumber (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).

ParamTypeRequired
classstring, 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.

ParamTypeRequired
filestring, relative to project rootyes
linenumber, 1-basedyes
colnumber, 1-basedyes

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.

ParamTypeRequired
codestring — the source text to lintyes
filenamestring, e.g. "src/ui/card.tsx" — never read from disk; selects rules and exception scopesyes

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.

ParamTypeRequired
namestring, kebab-caseyes
$typestring (DTCG type)yes
valuestring, e.g. "347px"yes
familiesstring[], e.g. ["w"]yes
reasonstring, ≥40 charsyes
ownerstring, e.g. "@design-systems"yes
expiresstring, YYYY-MM-DD, ≤12 months (≤90 days if literal)yes
allowedInstring[] (globs)yes
descriptionstringno
ticketstringno
literalbooleanno

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.

ParamTypeRequired
namestring, "<namespace>.<name>"yes
$typestring (DTCG type)yes
valuestring — a single literal; mutually exclusive with casesno
casesobject — an axis map, e.g. { $axis: "theme", light: "#fff", dark: "#0b0b0b" }; mutually exclusive with valueno
descriptionstringno

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."

ParamTypeRequired
namestring, 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.

ParamTypeRequired
classesstring[]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.

jsonc
// → 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.

ParamTypeRequired
codestring, e.g. "TAB-E113"yes

See also

Released under the MIT License.