@theme-registry/refract-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/server.js +451 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Petyo Stoyanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @theme-registry/refract-mcp
|
|
2
|
+
|
|
3
|
+
> A [Model Context Protocol](https://modelcontextprotocol.io) server for refract. It
|
|
4
|
+
> lets an AI agent (Claude Code, claude.ai, or any MCP client) **query and validate your project's
|
|
5
|
+
> theme** while authoring — the "middleware" that keeps an agent bound to the theme's real vocabulary
|
|
6
|
+
> instead of guessing.
|
|
7
|
+
|
|
8
|
+
It is **project-scoped**: it loads your `theme.config.(ts|js|mjs)` once at startup and serves queries
|
|
9
|
+
against it, so the agent asks about *your* theme without ever resending it. It reloads on change.
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
| Tool | Answers |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `resolveToken` | What does `colors.brand.dark` resolve to — its value, CSS `varName`, `unit`, and `derivedFrom`? |
|
|
16
|
+
| `listTokens` | What token paths exist (the addressable vocabulary)? |
|
|
17
|
+
| `findToken` | Which token paths start with a prefix? |
|
|
18
|
+
| `searchTokens` | Which tokens match a query on their path OR resolved value? |
|
|
19
|
+
| `listRecipes` | What recipes are defined? |
|
|
20
|
+
| `getClass` | What real class (and composed class-list) does a recipe get — with the project's configured prefix? |
|
|
21
|
+
| `renderRecipe` | What exact CSS does one recipe emit? |
|
|
22
|
+
| `checkContrast` | Do the theme's colour pairings pass WCAG-2 (+ advisory APCA)? |
|
|
23
|
+
| `validateTheme` | Is a candidate theme valid on every configured target? — returns **every** problem at once (collect-all), per target. |
|
|
24
|
+
| `diffTheme` | What's the blast radius of a candidate edit — which tokens moved, classes changed, pairings crossed a threshold, targets stopped building? |
|
|
25
|
+
|
|
26
|
+
`diffTheme` is the plan-then-apply guardrail: pass a **candidate edit** and it builds it against the
|
|
27
|
+
project's real adapters and reports the blast radius *before* the agent writes — the claim a token file
|
|
28
|
+
can't make. `validateTheme` surfaces refract's collect-all errors with their stable `code` per target, so
|
|
29
|
+
an agent fixes all problems in one pass and catches adapter-level rules (unknown state, naming collision)
|
|
30
|
+
a generic check would miss. `getClass` / `resolveToken` read the real emitted names, so the prefix matches
|
|
31
|
+
what ships. The query tools take no
|
|
32
|
+
`theme` argument — they read the loaded project theme.
|
|
33
|
+
|
|
34
|
+
## Architecture
|
|
35
|
+
|
|
36
|
+
Pure tools (`src/tools.ts`, unit-tested) operate on a built `Theme` the server holds; `callTool`
|
|
37
|
+
(`src/server.ts`) is the transport-agnostic dispatch core; the transport is the official
|
|
38
|
+
`@modelcontextprotocol/sdk` over stdio.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
agent ⇄ (MCP stdio, SDK) ⇄ callTool ⇄ tools.ts ⇄ the project theme (loaded once from theme.config)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Connect
|
|
45
|
+
|
|
46
|
+
Register the server with your agent. Once published:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
claude mcp add refract -- npx -y @theme-registry/refract-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or from a local build:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pnpm --filter @theme-registry/refract-mcp build # → dist/server.js (a runnable bin)
|
|
56
|
+
claude mcp add refract -- node ./packages/refract-mcp/dist/server.js
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The server auto-discovers `theme.config.(ts|js|mjs)` in the working directory; pass `--config <path>`
|
|
60
|
+
to point elsewhere. A `.ts` config needs the `typescript` optional peer (same as `refract build`).
|
|
61
|
+
|
|
62
|
+
For project scope, commit a `.mcp.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "mcpServers": { "refract": { "command": "npx", "args": ["-y", "@theme-registry/refract-mcp", "--config", "theme.config.ts"] } } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Test
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pnpm --filter @theme-registry/refract-mcp test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- **Stable** — published on the npm `latest` tag in lockstep with core and the adapters.
|
|
77
|
+
- It's the **refract half** of a *Figma → refract → AI* loop: an agent reads a design, authors a theme,
|
|
78
|
+
and `validateTheme` is the guardrail that stops it drifting.
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, watch } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { argv } from 'node:process';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { audit, createTheme, createNoopAdapter } from '@theme-registry/refract';
|
|
11
|
+
import { toDTCG } from '@theme-registry/refract/dtcg';
|
|
12
|
+
import { loadConfig, buildGuide, diffThemes } from '@theme-registry/refract/build';
|
|
13
|
+
|
|
14
|
+
/** Describe what a token derives from, read straight off the stored `Ref` graph (the `derivedFrom`). */
|
|
15
|
+
const describeDerivation = (ref) => {
|
|
16
|
+
var _a;
|
|
17
|
+
if (!ref || ref.ref === undefined)
|
|
18
|
+
return undefined; // terminal literal / external → nothing to derive
|
|
19
|
+
const arg = (a) => (a === undefined ? "" : `(${JSON.stringify(a)})`);
|
|
20
|
+
if ((_a = ref.modifiers) === null || _a === void 0 ? void 0 : _a.length)
|
|
21
|
+
return `${ref.ref} ${ref.modifiers.map((m) => `${m.fn}${arg(m.arg)}`).join(" → ")}`;
|
|
22
|
+
if (ref.fn)
|
|
23
|
+
return `${ref.ref} ${ref.fn}${arg(ref.arg)}`;
|
|
24
|
+
return ref.ref; // a plain alias
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Resolve one token path — enriched. Beyond the concrete `value`, returns the emitted CSS `varName`
|
|
28
|
+
* (from a class-emitting target, carrying its configured prefix — the most useful thing to hand an
|
|
29
|
+
* agent), the length `unit`, and `derivedFrom` (read off the stored `{ ref, fn/modifiers }` graph). A
|
|
30
|
+
* resolved literal is frozen; the varName is the live, re-themeable handle.
|
|
31
|
+
*/
|
|
32
|
+
const resolveToken = (base, path, targets = []) => {
|
|
33
|
+
var _a, _b;
|
|
34
|
+
const value = String(base.resolveToken(path));
|
|
35
|
+
const ref = base.tokens[path];
|
|
36
|
+
const varName = (_b = (_a = targets.map((t) => t.theme).find((t) => t.varName)) === null || _a === void 0 ? void 0 : _a.varName) === null || _b === void 0 ? void 0 : _b.call(_a, path);
|
|
37
|
+
const derivedFrom = describeDerivation(ref);
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
value,
|
|
41
|
+
...(varName ? { varName } : {}),
|
|
42
|
+
...((ref === null || ref === void 0 ? void 0 : ref.unit) ? { unit: ref.unit } : {}),
|
|
43
|
+
...(derivedFrom ? { derivedFrom } : {}),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** Every token path in the theme — the agent's addressable vocabulary. */
|
|
47
|
+
const listTokens = (theme) => Object.keys(theme.tokens);
|
|
48
|
+
/** Token paths starting with `prefix` — so an agent can discover names (`findToken("colors.brand")`). */
|
|
49
|
+
const findToken = (theme, prefix) => Object.keys(theme.tokens).filter((p) => p.startsWith(prefix));
|
|
50
|
+
/** Fuzzy token discovery: match `query` against a token's path OR its resolved value (case-insensitive). */
|
|
51
|
+
const searchTokens = (theme, query) => {
|
|
52
|
+
const q = query.toLowerCase();
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const path of Object.keys(theme.tokens)) {
|
|
55
|
+
let value = "";
|
|
56
|
+
try {
|
|
57
|
+
value = String(theme.resolveToken(path));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* leave value empty */
|
|
61
|
+
}
|
|
62
|
+
if (path.toLowerCase().includes(q) || value.toLowerCase().includes(q))
|
|
63
|
+
out.push({ path, value });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
/** Every recipe the theme defines, as `{ subsystem, group, variant }` identities. */
|
|
68
|
+
const listRecipes = (theme) => {
|
|
69
|
+
var _a;
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const [subsystem, sub] of Object.entries(theme.model.subsystems)) {
|
|
72
|
+
for (const [group, ruleSetGroup] of Object.entries((_a = sub.ruleSets) !== null && _a !== void 0 ? _a : {})) {
|
|
73
|
+
for (const variant of Object.keys(ruleSetGroup))
|
|
74
|
+
out.push({ subsystem, group, variant });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
const classesOf = (theme) => theme.classes;
|
|
80
|
+
/**
|
|
81
|
+
* The class (and full composed class-list) an agent should put on an element for a recipe — read from a
|
|
82
|
+
* target's **real** emitted classes, so the prefix/composition match what ships. Only class-emitting
|
|
83
|
+
* adapters (e.g. CSS) expose `.classes`; if the project configures none, this says so rather than
|
|
84
|
+
* inventing a canonical `dt-…` name that exists in no stylesheet.
|
|
85
|
+
*/
|
|
86
|
+
const getClass = (targets, id) => {
|
|
87
|
+
var _a, _b;
|
|
88
|
+
const named = targets.find((t) => classesOf(t.theme));
|
|
89
|
+
if (!named) {
|
|
90
|
+
const names = targets.map((t) => t.name).join(", ") || "(no targets configured)";
|
|
91
|
+
throw new Error(`getClass needs an adapter that emits class names (e.g. the CSS adapter) — none of [${names}] does`);
|
|
92
|
+
}
|
|
93
|
+
const leaf = (_b = (_a = classesOf(named.theme)[id.subsystem]) === null || _a === void 0 ? void 0 : _a[id.group]) === null || _b === void 0 ? void 0 : _b[id.variant];
|
|
94
|
+
if (leaf === undefined) {
|
|
95
|
+
throw new Error(`no recipe "${id.subsystem}.${id.group}.${id.variant}" — call listRecipes to see what exists`);
|
|
96
|
+
}
|
|
97
|
+
return typeof leaf === "string"
|
|
98
|
+
? { className: leaf, classList: [leaf], target: named.name }
|
|
99
|
+
: { className: leaf.className, classList: leaf.classList, target: named.name };
|
|
100
|
+
};
|
|
101
|
+
/** The exact CSS one recipe emits — from a class-emitting target. Throws when nothing emits CSS. */
|
|
102
|
+
const renderRecipe = (targets, id) => {
|
|
103
|
+
const named = targets.find((t) => typeof t.theme.renderRecipe === "function");
|
|
104
|
+
if (!named) {
|
|
105
|
+
const names = targets.map((t) => t.name).join(", ") || "(no targets configured)";
|
|
106
|
+
throw new Error(`renderRecipe needs an adapter that emits CSS (e.g. the CSS adapter) — none of [${names}] does`);
|
|
107
|
+
}
|
|
108
|
+
const render = named.theme.renderRecipe;
|
|
109
|
+
const css = render(id.subsystem, id.group, id.variant);
|
|
110
|
+
if (!css)
|
|
111
|
+
throw new Error(`no recipe "${id.subsystem}.${id.group}.${id.variant}" — call listRecipes to see what exists`);
|
|
112
|
+
return { css, target: named.name };
|
|
113
|
+
};
|
|
114
|
+
/** WCAG-2 contrast audit of the theme's colour pairings (+ advisory APCA) — `audit()` over MCP. */
|
|
115
|
+
const checkContrast = (theme, minWcag) => {
|
|
116
|
+
const result = audit(theme, minWcag ? { minWcag: minWcag } : {});
|
|
117
|
+
return { ok: result.ok, summary: result.summary, pairings: result.pairings };
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Validate a raw theme against every configured target, returning ALL problems per target (refract
|
|
121
|
+
* collects them) rather than the first. This is the anti-drift guardrail: an agent validates a
|
|
122
|
+
* candidate edit here — against the same adapters that will emit it — before it writes.
|
|
123
|
+
*/
|
|
124
|
+
const validateTheme = (raw, builders) => {
|
|
125
|
+
const perTarget = builders.map(({ name, build }) => {
|
|
126
|
+
var _a;
|
|
127
|
+
try {
|
|
128
|
+
build(raw);
|
|
129
|
+
return { target: name, ok: true, errors: [] };
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
const err = e;
|
|
133
|
+
return {
|
|
134
|
+
target: name,
|
|
135
|
+
ok: false,
|
|
136
|
+
code: err.code,
|
|
137
|
+
errors: err.failures ? [...err.failures] : [(_a = err.message) !== null && _a !== void 0 ? _a : String(e)],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return { ok: perTarget.every((t) => t.ok), perTarget };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* refract MCP server — a Model Context Protocol server over stdio, on the official
|
|
146
|
+
* `@modelcontextprotocol/sdk`. It is **project-scoped**: it loads the project's
|
|
147
|
+
* `theme.config.(ts|js|mjs)` once at startup (auto-discovered in the cwd, or via `--config <path>`),
|
|
148
|
+
* holds the built theme, and serves the query tools against it — so an agent asks about *this project's*
|
|
149
|
+
* theme without ever resending it. It watches the config and reloads on change.
|
|
150
|
+
*
|
|
151
|
+
* The tool logic lives in the pure `tools.ts`; `callTool` here is the transport-agnostic dispatch core
|
|
152
|
+
* (unit-testable) — the SDK handler is a thin wrapper over it.
|
|
153
|
+
*/
|
|
154
|
+
/** Read this package's version from its package.json (single source of truth for the handshake). */
|
|
155
|
+
function serverVersion() {
|
|
156
|
+
var _a;
|
|
157
|
+
try {
|
|
158
|
+
return (_a = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version) !== null && _a !== void 0 ? _a : "0.0.0";
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return "0.0.0";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** The manifest schema version this server speaks (matches `refract build --guide`). Echoed in the handshake. */
|
|
165
|
+
const GUIDE_SCHEMA = 1;
|
|
166
|
+
const RESOURCE_URIS = { llms: "refract://llms.txt", manifest: "refract://manifest.json" };
|
|
167
|
+
// --- held state ---
|
|
168
|
+
let held = null;
|
|
169
|
+
let configPathArg;
|
|
170
|
+
/** Load (or reload) the project's theme config and build it once per target. Throws if it can't be resolved/built. */
|
|
171
|
+
async function loadTheme(configPath) {
|
|
172
|
+
var _a, _b;
|
|
173
|
+
const { config, path } = await loadConfig({ configPath });
|
|
174
|
+
// Match what `refract build` emits, so resolved values/classes are the project's real ones.
|
|
175
|
+
const buildOpts = { units: config.units, baseFontSize: config.baseFontSize, media: config.media };
|
|
176
|
+
const targets = config.targets.map((t, i) => {
|
|
177
|
+
var _a, _b;
|
|
178
|
+
return ({
|
|
179
|
+
name: (_b = (_a = t.name) !== null && _a !== void 0 ? _a : t.outDir) !== null && _b !== void 0 ? _b : `target-${i}`,
|
|
180
|
+
adapter: t.adapter,
|
|
181
|
+
theme: createTheme(config.raw, { adapter: t.adapter, ...buildOpts }),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
// A config with no targets still answers token/model queries; build a noop base for them.
|
|
185
|
+
const base = (_b = (_a = targets[0]) === null || _a === void 0 ? void 0 : _a.theme) !== null && _b !== void 0 ? _b : createTheme(config.raw, { adapter: createNoopAdapter(), ...buildOpts });
|
|
186
|
+
return { targets, base, raw: config.raw, path, buildOpts };
|
|
187
|
+
}
|
|
188
|
+
/** The set of real-adapter themes `getClass` reads class names from. */
|
|
189
|
+
const namedThemes = (state) => state.targets.map((t) => ({ name: t.name, theme: t.theme }));
|
|
190
|
+
/**
|
|
191
|
+
* Builders that re-compile a candidate against each configured target's real adapter (with the project's
|
|
192
|
+
* build options), so `validateTheme` sees the same throws `refract build` would. When no targets are
|
|
193
|
+
* configured, fall back to a single `core` builder (noop adapter) — core-level checks only, honestly
|
|
194
|
+
* labelled — rather than claiming a target validated it.
|
|
195
|
+
*/
|
|
196
|
+
function buildersFor(state) {
|
|
197
|
+
var _a, _b;
|
|
198
|
+
const opts = (_a = state === null || state === void 0 ? void 0 : state.buildOpts) !== null && _a !== void 0 ? _a : {};
|
|
199
|
+
const list = ((_b = state === null || state === void 0 ? void 0 : state.targets) !== null && _b !== void 0 ? _b : []).map((t) => ({
|
|
200
|
+
name: t.name,
|
|
201
|
+
build: (raw) => void createTheme(raw, { adapter: t.adapter, ...opts }),
|
|
202
|
+
}));
|
|
203
|
+
if (list.length === 0) {
|
|
204
|
+
list.push({ name: "core", build: (raw) => void createTheme(raw, { adapter: createNoopAdapter(), ...opts }) });
|
|
205
|
+
}
|
|
206
|
+
return list;
|
|
207
|
+
}
|
|
208
|
+
const EMPTY_DIFF = { tokens: [], classes: [], contrast: [], summary: { tokensChanged: 0, classesChanged: 0, pairingsCrossed: 0 } };
|
|
209
|
+
/**
|
|
210
|
+
* The `diffTheme` guardrail: build a candidate against the project's real adapters and report the blast
|
|
211
|
+
* radius vs the loaded theme — which tokens moved, which classes changed, which pairings crossed a
|
|
212
|
+
* threshold, and whether every target still builds. The diff runs through a class-emitting target (so
|
|
213
|
+
* `classes` is populated); if the candidate doesn't build there, the diff is empty and `targets` carries
|
|
214
|
+
* the failure. Plan-then-apply, before the agent writes.
|
|
215
|
+
*/
|
|
216
|
+
function diffHeld(state, candidateRaw) {
|
|
217
|
+
var _a, _b, _c;
|
|
218
|
+
const validation = validateTheme(candidateRaw, buildersFor(state));
|
|
219
|
+
const pick = (_a = state.targets.find((t) => typeof t.theme.renderRecipe === "function")) !== null && _a !== void 0 ? _a : state.targets[0];
|
|
220
|
+
const baseTheme = (_b = pick === null || pick === void 0 ? void 0 : pick.theme) !== null && _b !== void 0 ? _b : state.base;
|
|
221
|
+
let diff = EMPTY_DIFF;
|
|
222
|
+
try {
|
|
223
|
+
diff = diffThemes(baseTheme, createTheme(candidateRaw, { adapter: (_c = pick === null || pick === void 0 ? void 0 : pick.adapter) !== null && _c !== void 0 ? _c : createNoopAdapter(), ...state.buildOpts }));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* candidate doesn't build — reported via `targets` */
|
|
227
|
+
}
|
|
228
|
+
return { ok: validation.ok, diff, targets: validation.perTarget };
|
|
229
|
+
}
|
|
230
|
+
// Dirs never worth reloading on (vendored deps, build output, VCS). Skipped under the recursive watch.
|
|
231
|
+
const WATCH_IGNORE = /(^|[/\\])(node_modules|dist|out|\.git|coverage)([/\\]|$)/;
|
|
232
|
+
// Source files a theme graph is authored in — a change to any of them may change the built theme.
|
|
233
|
+
const WATCH_SOURCE = /\.(ts|mjs|cjs|js|json)$/;
|
|
234
|
+
/**
|
|
235
|
+
* Watch the config's directory **recursively** and rebuild on any source-file change — so a theme split
|
|
236
|
+
* across `./tokens/*.ts` (imported transitively by the config) reloads, not just an edit to the config
|
|
237
|
+
* file itself (MCP-6). Debounced; keeps the previous theme on a failed reload. Recursive `fs.watch` is
|
|
238
|
+
* supported on macOS/Windows and modern Linux — falls back to a flat watch where it isn't.
|
|
239
|
+
*/
|
|
240
|
+
function watchConfig() {
|
|
241
|
+
if (!held)
|
|
242
|
+
return;
|
|
243
|
+
let timer;
|
|
244
|
+
const onChange = (_event, filename) => {
|
|
245
|
+
if (!filename)
|
|
246
|
+
return;
|
|
247
|
+
const f = filename.toString();
|
|
248
|
+
if (WATCH_IGNORE.test(f) || !WATCH_SOURCE.test(f))
|
|
249
|
+
return;
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
timer = setTimeout(() => {
|
|
252
|
+
loadTheme(configPathArg)
|
|
253
|
+
.then((next) => {
|
|
254
|
+
held = next;
|
|
255
|
+
process.stderr.write(`refract-mcp: reloaded theme from ${next.path}\n`);
|
|
256
|
+
})
|
|
257
|
+
.catch((e) => process.stderr.write(`refract-mcp: reload failed (kept previous theme): ${e.message}\n`));
|
|
258
|
+
}, 150);
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
const dir = dirname(held.path);
|
|
262
|
+
let watcher;
|
|
263
|
+
try {
|
|
264
|
+
watcher = watch(dir, { recursive: true }, onChange);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Platform without recursive support — fall back to a flat watch of the config dir.
|
|
268
|
+
watcher = watch(dir, onChange);
|
|
269
|
+
}
|
|
270
|
+
// Don't let the watcher keep the process alive: when the client closes stdin, the server should exit.
|
|
271
|
+
watcher.unref();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* watching is best-effort — a missing dir or platform quirk shouldn't crash the server */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Render the project's self-documenting guide (`llms.txt` + `manifest.json`) IN MEMORY from the SAME
|
|
279
|
+
* real names the tools return (via `getClass`) + the DTCG token export — so the MCP's resources, the
|
|
280
|
+
* emitted `--guide` files, and the query tools are one source of truth (MCP-5 coherence). The manifest
|
|
281
|
+
* carries `schema: {@link GUIDE_SCHEMA}`.
|
|
282
|
+
*/
|
|
283
|
+
function guideFiles(state) {
|
|
284
|
+
const named = namedThemes(state);
|
|
285
|
+
const recipes = listRecipes(state.base).flatMap((id) => {
|
|
286
|
+
try {
|
|
287
|
+
return [{ ...id, name: getClass(named, id).className }];
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return []; // a target that mints no class name for this recipe
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const descriptor = {
|
|
294
|
+
format: "css",
|
|
295
|
+
summary: [
|
|
296
|
+
"Apply the recipe class names below to your elements (import the emitted stylesheet once).",
|
|
297
|
+
"Token values are CSS custom properties (var(--…)) — runtime theming = override the variables under a selector or [data-theme].",
|
|
298
|
+
"These names are the live compiled theme's — the same ones the MCP getClass / resolveToken tools return.",
|
|
299
|
+
],
|
|
300
|
+
recipes,
|
|
301
|
+
};
|
|
302
|
+
return buildGuide(descriptor, toDTCG(state.base), { files: [] }).files;
|
|
303
|
+
}
|
|
304
|
+
// --- tool definitions (schemas carry NO `theme` — it's project-scoped) ---
|
|
305
|
+
const TOOL_DEFS = [
|
|
306
|
+
{ name: "resolveToken", description: "Resolve a token path (e.g. colors.brand.dark) to its value + its emitted CSS variable name (varName), unit, and derivedFrom (what it derives from) in the loaded theme.",
|
|
307
|
+
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
|
|
308
|
+
{ name: "listTokens", description: "List every token path in the loaded theme.",
|
|
309
|
+
inputSchema: { type: "object", properties: {}, required: [] } },
|
|
310
|
+
{ name: "findToken", description: "List token paths that start with a prefix (discover names).",
|
|
311
|
+
inputSchema: { type: "object", properties: { prefix: { type: "string" } }, required: ["prefix"] } },
|
|
312
|
+
{ name: "searchTokens", description: "Find token paths matching a query on the path OR the resolved value (fuzzy, case-insensitive).",
|
|
313
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
|
|
314
|
+
{ name: "listRecipes", description: "List every recipe as { subsystem, group, variant }.",
|
|
315
|
+
inputSchema: { type: "object", properties: {}, required: [] } },
|
|
316
|
+
{ name: "getClass", description: "Get the real class + composed class-list for a recipe (from the project's class-emitting adapter, e.g. CSS — carries its configured prefix).",
|
|
317
|
+
inputSchema: { type: "object", properties: { subsystem: { type: "string" }, group: { type: "string" }, variant: { type: "string" } }, required: ["subsystem", "group", "variant"] } },
|
|
318
|
+
{ name: "renderRecipe", description: "The exact CSS one recipe emits (base + its state/breakpoint rules), from the project's class-emitting adapter.",
|
|
319
|
+
inputSchema: { type: "object", properties: { subsystem: { type: "string" }, group: { type: "string" }, variant: { type: "string" } }, required: ["subsystem", "group", "variant"] } },
|
|
320
|
+
{ name: "checkContrast", description: "WCAG-2 contrast audit of the loaded theme's colour pairings (+ advisory APCA). Optional minWcag pass bar (AA|AAA|AA-large).",
|
|
321
|
+
inputSchema: { type: "object", properties: { minWcag: { type: "string", description: "pass bar: AA (default) | AAA | AA-large" } }, required: [] } },
|
|
322
|
+
{ name: "validateTheme", description: "Validate a candidate raw theme (or the loaded theme) against every configured target adapter — returns ALL problems per target at once.",
|
|
323
|
+
inputSchema: { type: "object", properties: { theme: { type: "object", description: "a candidate raw theme; omit to validate the loaded one" } }, required: [] } },
|
|
324
|
+
{ name: "diffTheme", description: "Diff a candidate raw theme against the loaded one — which tokens moved, which classes changed, which contrast pairings crossed a threshold, and whether every target still builds. Plan-then-apply: see the blast radius before you write.",
|
|
325
|
+
inputSchema: { type: "object", properties: { theme: { type: "object", description: "the candidate raw theme to compare against the loaded one" } }, required: ["theme"] } },
|
|
326
|
+
{ name: "reload", description: "Reload the project's theme config from disk.",
|
|
327
|
+
inputSchema: { type: "object", properties: {}, required: [] } },
|
|
328
|
+
];
|
|
329
|
+
/**
|
|
330
|
+
* Transport-agnostic tool dispatch — pure over the passed `state`. Query tools need a loaded theme;
|
|
331
|
+
* `validateTheme` works on a candidate even without one. `reload` is handled by the server (it mutates
|
|
332
|
+
* state + does I/O), not here.
|
|
333
|
+
*/
|
|
334
|
+
function callTool(name, args, state) {
|
|
335
|
+
var _a, _b;
|
|
336
|
+
const loaded = () => {
|
|
337
|
+
if (!state)
|
|
338
|
+
throw new Error("no theme loaded — launch refract-mcp in a project with a theme.config.(ts|js|mjs), or pass --config <path>");
|
|
339
|
+
return state;
|
|
340
|
+
};
|
|
341
|
+
switch (name) {
|
|
342
|
+
case "resolveToken":
|
|
343
|
+
return resolveToken(loaded().base, args.path, namedThemes(loaded()));
|
|
344
|
+
case "listTokens":
|
|
345
|
+
return listTokens(loaded().base);
|
|
346
|
+
case "findToken":
|
|
347
|
+
return findToken(loaded().base, args.prefix);
|
|
348
|
+
case "searchTokens":
|
|
349
|
+
return searchTokens(loaded().base, args.query);
|
|
350
|
+
case "listRecipes":
|
|
351
|
+
return listRecipes(loaded().base);
|
|
352
|
+
case "getClass":
|
|
353
|
+
return getClass(namedThemes(loaded()), { subsystem: args.subsystem, group: args.group, variant: args.variant });
|
|
354
|
+
case "renderRecipe":
|
|
355
|
+
return renderRecipe(namedThemes(loaded()), { subsystem: args.subsystem, group: args.group, variant: args.variant });
|
|
356
|
+
case "checkContrast":
|
|
357
|
+
return checkContrast(loaded().base, args.minWcag);
|
|
358
|
+
case "validateTheme": {
|
|
359
|
+
// A candidate wins; else the loaded theme. With neither, there is nothing to validate — say so
|
|
360
|
+
// rather than green-lighting `{}` (which builds cleanly and would teach the agent to trust it).
|
|
361
|
+
const candidate = (_a = args.theme) !== null && _a !== void 0 ? _a : state === null || state === void 0 ? void 0 : state.raw;
|
|
362
|
+
if (candidate === undefined) {
|
|
363
|
+
throw new Error("nothing to validate — pass a candidate `theme`, or launch refract-mcp in a project with a theme.config.(ts|js|mjs)");
|
|
364
|
+
}
|
|
365
|
+
return validateTheme(candidate, buildersFor(state));
|
|
366
|
+
}
|
|
367
|
+
case "diffTheme": {
|
|
368
|
+
// A diff needs a base to compare against — the loaded project theme.
|
|
369
|
+
const candidate = (_b = args.theme) !== null && _b !== void 0 ? _b : state === null || state === void 0 ? void 0 : state.raw;
|
|
370
|
+
if (candidate === undefined) {
|
|
371
|
+
throw new Error("nothing to diff — pass a candidate `theme`, or launch refract-mcp in a project with a theme.config.(ts|js|mjs)");
|
|
372
|
+
}
|
|
373
|
+
return diffHeld(loaded(), candidate);
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
throw new Error(`unknown tool "${name}"`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** Run a tool call, handling the async `reload` at the server layer and delegating the rest to `callTool`. */
|
|
380
|
+
async function runToolCall(name, args) {
|
|
381
|
+
if (name === "reload") {
|
|
382
|
+
held = await loadTheme(configPathArg);
|
|
383
|
+
return { reloaded: true, path: held.path };
|
|
384
|
+
}
|
|
385
|
+
return callTool(name, args, held);
|
|
386
|
+
}
|
|
387
|
+
/** Build the MCP server — tools + the self-documenting `llms.txt` / `manifest.json` resources. */
|
|
388
|
+
function createServer() {
|
|
389
|
+
const server = new Server({ name: "refract-mcp", version: serverVersion() }, {
|
|
390
|
+
capabilities: { tools: {}, resources: { listChanged: true } },
|
|
391
|
+
// The handshake advertises the guide schema this server speaks, so an agent can reconcile the
|
|
392
|
+
// MCP resources, the emitted `--guide` files, and the installed skills against one version.
|
|
393
|
+
instructions: `refract-mcp serves the project's compiled theme as tools + resources (guide schema ${GUIDE_SCHEMA}). Read the "refract://manifest.json" resource for the machine index (schema ${GUIDE_SCHEMA}, real class names + token paths) and "refract://llms.txt" for prose. Use diffTheme to see a candidate edit's blast radius before writing.`,
|
|
394
|
+
});
|
|
395
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
|
|
396
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
397
|
+
var _a;
|
|
398
|
+
const { name, arguments: args } = request.params;
|
|
399
|
+
try {
|
|
400
|
+
const result = await runToolCall(name, (_a = args) !== null && _a !== void 0 ? _a : {});
|
|
401
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
// Resources: the same guide `refract build --guide` emits, rendered live from the loaded theme.
|
|
408
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
409
|
+
resources: [
|
|
410
|
+
{ uri: RESOURCE_URIS.llms, name: "llms.txt", description: "Prose guide to consuming this theme (real class names).", mimeType: "text/plain" },
|
|
411
|
+
{ uri: RESOURCE_URIS.manifest, name: "manifest.json", description: `Machine index of the theme (schema ${GUIDE_SCHEMA}): class names + DTCG tokens.`, mimeType: "application/json" },
|
|
412
|
+
],
|
|
413
|
+
}));
|
|
414
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
415
|
+
var _a, _b;
|
|
416
|
+
const { uri } = request.params;
|
|
417
|
+
if (!held)
|
|
418
|
+
throw new Error("no theme loaded — launch refract-mcp in a project with a theme.config.(ts|js|mjs), or pass --config <path>");
|
|
419
|
+
const files = guideFiles(held);
|
|
420
|
+
if (uri === RESOURCE_URIS.llms)
|
|
421
|
+
return { contents: [{ uri, mimeType: "text/plain", text: (_a = files["llms.txt"]) !== null && _a !== void 0 ? _a : "" }] };
|
|
422
|
+
if (uri === RESOURCE_URIS.manifest)
|
|
423
|
+
return { contents: [{ uri, mimeType: "application/json", text: (_b = files["manifest.json"]) !== null && _b !== void 0 ? _b : "" }] };
|
|
424
|
+
throw new Error(`unknown resource "${uri}"`);
|
|
425
|
+
});
|
|
426
|
+
return server;
|
|
427
|
+
}
|
|
428
|
+
/** Entry: resolve the config, load the theme (best-effort), start watching, then serve over stdio. */
|
|
429
|
+
async function start(args) {
|
|
430
|
+
const { values } = parseArgs({ args, options: { config: { type: "string" } }, allowPositionals: true });
|
|
431
|
+
configPathArg = values.config;
|
|
432
|
+
try {
|
|
433
|
+
held = await loadTheme(configPathArg);
|
|
434
|
+
process.stderr.write(`refract-mcp: serving theme from ${held.path}\n`);
|
|
435
|
+
watchConfig();
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
process.stderr.write(`refract-mcp: no theme loaded (${e.message}). Query tools will report it; validateTheme still works on a candidate.\n`);
|
|
439
|
+
}
|
|
440
|
+
const transport = new StdioServerTransport();
|
|
441
|
+
// When the client closes the pipe (session over), shut down — fs.watch would otherwise keep the
|
|
442
|
+
// event loop alive and orphan the process.
|
|
443
|
+
transport.onclose = () => process.exit(0);
|
|
444
|
+
await createServer().connect(transport);
|
|
445
|
+
}
|
|
446
|
+
// Run when invoked as the bin entry (ESM: compare our URL to the launched script), not when imported by tests.
|
|
447
|
+
if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
|
|
448
|
+
void start(argv.slice(2));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { callTool, createServer, guideFiles, loadTheme, serverVersion, start };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theme-registry/refract-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for refract — query + validate a project's theme from an AI agent.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Petyo Stoyanov",
|
|
7
|
+
"email": "petyosv@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/theme-registry/refract.git",
|
|
12
|
+
"directory": "packages/refract-mcp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/theme-registry/refract/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/theme-registry/refract#readme",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"refract-mcp": "./dist/server.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"/dist"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
+
"@theme-registry/refract": "^0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"typescript": "*"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"typescript": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@rollup/plugin-typescript": "^11.1.6",
|
|
40
|
+
"@types/node": "^24",
|
|
41
|
+
"rimraf": "^5.0.5",
|
|
42
|
+
"rollup": "^3.29.4",
|
|
43
|
+
"typescript": "^5.4.2",
|
|
44
|
+
"vitest": "^4.1.10",
|
|
45
|
+
"@theme-registry/refract-css": "^0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rollup -c",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
50
|
+
"clean": "rimraf dist",
|
|
51
|
+
"test": "vitest run"
|
|
52
|
+
}
|
|
53
|
+
}
|