ds-canon
# ds-canon
Your design system's canon, queryable by agents. What exists, what is deprecated, what breaks if you touch it.
Design systems decay into tribal knowledge the moment the token sheet drifts from the code and the person who remembers why gets pulled onto another project. Agents writing UI code make this worse: they hallucinate plausible token names and confident-sounding component APIs because they have nothing authoritative to check against. ds-canon puts your design system's tokens, components, conventions, and deprecations behind a read-only MCP server, so agents and the humans directing them query the same system of record instead of guessing.
## The path from zero to a drift sweep
The user journey, end to end:
```mermaid
flowchart LR
A[Find the repo] --> B[npx -y ds-canon: try the demo]
B --> C[Clone and build]
C --> D[Register as an MCP server]
D --> E[Query the tools]
E --> F[Author your own canon]
F --> G[Boot your system]
G --> H[Sweep for drift]
H --> I[Read the findings]
```
## The 60-second demo
Run it straight from npm, nothing to clone:
```bash
npx -y ds-canon
```
You'll see a one-line banner confirming what loaded:
```
ds-canon v0.2.0 serving Nimbus DS (40 tokens, 9 components) from /path/to/ds-canon/fixtures, read-only. Waiting for an MCP client on stdin (Ctrl+C to exit). For a one-off CLI drift scan, run: ds-canon drift <path>.
```
Then it sits there. That is correct: ds-canon is a stdio MCP server, so after the banner it blocks waiting for a client to speak JSON-RPC on stdin. It is not hung. Register it with an MCP client (below), run the bundled smoke-test client (`node examples/mcp-client.js`), or press Ctrl+C to exit.
The banner prints to **stderr**, not stdout, because stdout is reserved for the MCP protocol stream; a stray log line on stdout would corrupt the JSON-RPC frames a client reads. So if you are watching stdout you will see nothing until a client connects, and that is by design.
The server ships with a fixture design system called Nimbus DS so you can try it immediately, no setup required. Point an MCP-aware agent at it and ask it real questions.
**"Which accent color should I use, and is anything deprecated?"**
The agent calls `list_tokens` with `group: "color", query: "color.accent"`, then `whats_deprecated`. Real output, verbatim:
```json
{
"tokens": [
{
"name": "color.accent.primary",
"value": "#3B5BDB",
"type": "color",
"group": "color",
"status": "active",
"description": "Primary brand accent. Used for primary actions, active navigation state, and focus affordances."
},
{
"name": "color.accent.secondary",
"value": "#5C7CFA",
"type": "color",
"group": "color",
"status": "active",
"description": "Secondary accent for lower-emphasis interactive elements that still need to read as brand-colored."
},
{
"name": "color.accent.legacy",
"value": "#4C6EF5",
"type": "color",
"group": "color",
"status": "deprecated",
"description": "Original brand blue from the v1.x palette. Slightly less saturated than accent.primary; kept only for Banner and LegacyButton until both migrate.",
"deprecatedBy": "color.accent.primary"
}
]
}
```
(The `query` parameter is a substring match on name and description, so a looser query like `"accent"` also surfaces tokens whose descriptions mention accent usage. Scoping the query to the name prefix keeps the answer tight.)
```json
{
"deprecated": [
{ "name": "color.accent.legacy", "kind": "token", "deprecatedBy": "color.accent.primary", "dependentCount": 1 },
{ "name": "LegacyButton", "kind": "component", "deprecatedBy": "Button", "dependentCount": 0 }
],
"legacy": []
}
```
The agent now knows to recommend `color.accent.primary` and to flag `color.accent.legacy` as on its way out, with one live component (`Banner`) still depending on it. The separate `legacy` array (empty here) is for values that are live and intentionally kept, not scheduled for removal, so they never get mixed in with the migrate-off-me list.
**"I want to change `space.inset.md`. What will it affect?"**
This is the question a design system actually needs to answer before anyone touches a shared value. The agent calls `find_usages`:
```json
{
"entity": "space.inset.md",
"usages": [
{ "dependent": "Button", "dependentKind": "component", "relation": "consumes token" },
{ "dependent": "Card", "dependentKind": "component", "relation": "consumes token" },
{ "dependent": "Field", "dependentKind": "component", "relation": "consumes token" },
{ "dependent": "Modal", "dependentKind": "component", "relation": "consumes token" }
]
}
```
Blast radius, in one call: four components, named exactly. No spelunking through a component library to find every place `12px` got typed in by hand.
**"Write a secondary button that follows our conventions."**
The agent calls `get_component` for `Button` (props, variants, the tokens it consumes, and its `doNotUse` guidance) and `get_conventions` for the color topic, then writes the component. Now suppose it (or a human) had instead hardcoded the color:
```jsx
<button style={{ background: '#3B5BDB', padding: '12px' }}>Save</button>
```
Running that snippet through `check_token_drift` catches both literals:
```json
{
"findings": [
{
"severity": "error",
"raw": "#3B5BDB",
"suggestion": "color.accent.primary",
"message": "Hardcoded value #3B5BDB matches token \"color.accent.primary\". Use the token instead of the raw value."
},
{
"severity": "error",
"raw": "12px",
"suggestion": "space.inset.md",
"message": "Hardcoded value 12px matches token \"space.inset.md\". Use the token instead of the raw value."
}
]
}
```
`#3B5BDB` and `12px` are exact token values, so each finding is `error`: a token already exists, so this should be fixed. Findings come in three tiers. `error` means an exact token exists for the value. `warn` means a near-miss (a value close to a token, or an off-scale spacing value near a step) worth a look. `info` covers everything else: unmatched values, and matches whose token role does not fit the property (a background token suggested for a `color` property is reported honestly, not as an action).
## Install
ds-canon runs as a local MCP server over stdio. There is no separate service to deploy.
**mcp.json** (Claude Desktop, or a project-level `.mcp.json` for Claude Code):
```json
{
"mcpServers": {
"ds-canon": {
"command": "npx",
"args": ["-y", "ds-canon"]
}
}
}
```
**Claude Code, one line:**
```bash
claude mcp add ds-canon -- npx -y ds-canon
```
**Claude Desktop:** add the same `mcpServers` entry to your `claude_desktop_config.json` and restart the app.
Working from a clone instead (for development or custom fixtures): `git clone`, `npm install`, `npm run build`, then point `command` at `node` with `args: ["/absolute/path/to/ds-canon/dist/index.js"]`.
## Tools
Eight tools, all read-only.
| Tool | What it answers | Key inputs |
|---|---|---|
| `list_tokens` | What tokens exist? | `group?`, `status?`, `query?` |
| `get_token` | What is this token and who uses it? | `name` |
| `list_components` | What components exist? | `status?`, `tag?` |
| `get_component` | What does this component look like? | `name` |
| `find_usages` | What breaks if I change this? | `entity` |
| `whats_deprecated` | What should I stop using? | none |
| `get_conventions` | What are the house rules? | `topic?` |
| `check_token_drift` | Does this code drift from the token system? | `snippet` or `path`, `lang?` |
`get_token`, `get_component`, and `find_usages` look up by exact name; a miss returns a `not_found` error with up to three closest-name suggestions instead of an empty result, so a typo doesn't read as "this doesn't exist."
`check_token_drift` takes either a `snippet` string or a `path` to a single file (read-only, 20MB cap). Give it a `path` and every finding carries a 1-based `line`. `whats_deprecated` returns two arrays: `deprecated` (scheduled for removal) and `legacy` (live and intentionally kept, see below), so a rebrand alias you keep on purpose does not read as something to migrate off.
## Drift on the command line (CI)
Drift is also reachable without an MCP client, for pre-commit hooks and CI:
```bash
npx ds-canon drift "src/**/*.css" --fixtures /path/to/your/fixtures --fail-on error
```
It reads the files directly (a file, a directory, or a glob), prints a `file:line` table, and exits non-zero when findings meet the threshold. `--fail-on error` (the default) fails only when an exact token exists for a hardcoded value; `--fail-on warn` also fails on near-misses. `--fixtures` defaults to `DS_CANON_FIXTURES`, then the bundled Nimbus fixtures. The CLI and the MCP tool share one scan path, so a finding reads the same either way.
## The bundled smoke-test client
`examples/mcp-client.js` is a tiny, dependency-free stdio client (initialize, `tools/list`, `tools/call`). It doubles as a smoke test: after `npm run build`, run
```bash
node examples/mcp-client.js
```
and it starts the server, lists the tools, runs a drift check, and prints a token count, then exits. If you want to see the JSON-RPC framing ds-canon expects from a client, this is the shortest complete example.
## Pitfalls
Two behaviors surprised early users. Both are handled now; this is how they work so the output is never misread.
- **Definitions are not usages.** A line that defines a custom property (`--card: #fff;`) is the token's own source, not a place that drifted from it, so `check_token_drift` never flags the value on a `--name:` declaration. It handles several declarations packed on one line and names with digits (`--paper-2:`). Only actual usages (`background: #fff`) are flagged. Earlier versions flagged every `:root` definition, which made a fully tokenized stylesheet look like nothing but drift.
- **No space tokens means no spacing noise.** If your canon defines zero `space.*` tokens (a legitimate choice), px values are not measured against a scale that does not exist. Instead of one "off-scale" finding per literal, you get a single info noting that spacing literals were not checked. The same rule applies to font-size and line-height literals when no `type`/`dimension` tokens are defined.
## Point it at your own system
ds-canon reads three files from a fixture directory: `tokens.json`, `components.json`, and `conventions.md`. By default it loads the bundled Nimbus DS fixtures. Set `DS_CANON_FIXTURES` to point it at your own:
```bash
DS_CANON_FIXTURES=/path/to/your/design-system node dist/index.js
```
or in `mcp.json`:
```json
{
"mcpServers": {
"ds-canon": {
"command": "npx",
"args": ["-y", "ds-canon"],
"env": { "DS_CANON_FIXTURES": "/path/to/your/design-system" }
}
}
}
```
`tokens.json` accepts the tested flat, string-valued [W3C DTCG](https://www.designtokens.org/) token shape: groups may be nested, and each token leaf must provide string `$value` and `$type` fields, with optional string `$description`. Style Dictionary or Tokens Studio exports work only when they match that shape. Composite values and tool-specific export shapes need an adapter before loading. Deprecation and aliasing use two extensions on top of the base spec: an `$extensions["ds-canon"]` block for `status`/`deprecatedBy`, and DTCG's own `{token.path}` reference syntax for aliases. The server also requires `components.json`, a flat `{ meta, components }` shape matching the `DsComponent` type in `src/types.ts`, and `conventions.md`, with one `## topic` section per convention (`naming`, `spacing`, `color`, `accessibility`, `deprecation`) plus `Rule:`, `Rationale:`, and `Example:` lines. The loader validates all three at startup and throws a specific file-and-field-level error on malformed input rather than serving a partially loaded system.
Two `$extensions["ds-canon"]` features cover the messy parts of a real system. Set a token's `status` to `legacy` for a value that is live and intentionally kept but no longer preferred (a rebrand alias), distinct from `deprecated`, which implies removal. And declare intentional raw values so drift never flags them, with a top-level `sanctionedLiterals` list where each entry needs a `value` and a required `reason`:
```json
{
"$extensions": {
"ds-canon": {
"sanctionedLiterals": [
{ "value": "#f7f7f5", "reason": "page background, intentionally not tokenized" }
]
}
},
"color": {}
}
```
For a full worked example that converts a flat CSS custom-property sheet into this shape, see [docs/adopting-your-own-system.md](docs/adopting-your-own-system.md).
## Why read-only, why stdio, why no network
Every tool in ds-canon reads from an in-memory index built once at startup. Nothing in this server writes to the fixture files, calls out to a network, or accepts write operations of any kind. That's not an implementation gap, it's the point: a design system's system of record should not be mutable by the same agents that consume it, and a tool that only answers "what exists" cannot be tricked into becoming a tool that changes what exists.
Running over stdio means ds-canon has no exposed network listener. Risk is limited to local process input, file access, and parser behavior. The process caps each fixture file at 20 MB and caps fuzzy name and entity lookups at 256 characters before similarity work begins. The deployment model doubles as the governance model: install it, point it at your fixtures, and every agent that would otherwise guess now has one unwritable source of truth to query instead.
## How this was built
ds-canon was built by a multi-agent factory in an afternoon: parallel contract-first builders working from frozen type definitions, adversarial challengers doing black-box QA and architecture review against the built server, and agent-to-agent fix loops that dispositioned every finding before the next phase started. Of 28 findings, 24 were fully fixed, 2 were accepted or required no change with written rationale, and 2 were partially fixed with scoped follow-on enhancements deferred. The full build log, prompts, and challenge reports are in `factory/`.
## License
MIT. See [LICENSE](LICENSE).
Jay Trainer, Sr. Director, Product Design, AI-Native. [jaytrainerdesign.com](https://jaytrainerdesign.com)
TDQS
Scored across 8 tools
Each tool has a clearly distinct purpose: listing vs. retrieving specific entities, finding usages, checking deprecations, conventions, and code drift. The overlap between whats_deprecated and filtered token/component lists is minimal because the former is specifically about deprecated items and their migration targets.
Most tools follow a verb_noun pattern (list_tokens, get_component, find_usages, check_token_drift), but whats_deprecated breaks the pattern with a question-style name. Overall the naming is predictable and readable, with one minor deviation.
8 tools is well-scoped for a design system canonical server. Each tool addresses a distinct need (querying, detail lookup, dependency analysis, conventions, drift checking) and none feel redundant or unnecessary.
The tool surface comprehensively covers the design system domain: token and component listing/detail, deprecation info, reverse dependencies, conventions, and code drift detection. For a read-only reference server, the coverage is complete and leaves no obvious dead ends.