Repository navigation
Expand file tree
/
Copy pathserver.mjs
More file actions
executable file
·342 lines (318 loc) · 15.3 KB
/
Copy pathserver.mjs
File metadata and controls
executable file
·342 lines (318 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
#!/usr/bin/env node
/**
* ux-ui-agent-skills as an MCP server.
*
* The kit's gates are plain Node and Python and never depended on which agent
* was reading - that is why `AGENTS.md` works for Codex and Cursor. This takes
* the same half one step further: any MCP client can now RUN the gates, not
* just read the doctrine.
*
* ## Why this speaks JSON-RPC directly
*
* The kit ships with `"dependencies": {}` and says so on its front page. Adding
* an SDK to expose it would make that false for every install, so the protocol
* is implemented here: MCP is JSON-RPC 2.0 over stdio with four methods that
* matter, and the whole handshake is forty lines. Written against the
* 2025-06-18 specification, read rather than remembered.
*
* ## What it refuses to do
*
* It runs gates and returns what they printed. It does not summarise them, does
* not convert a failure into advice, and does not report a number no gate
* produced. The kit's first rule is "never state a number you did not measure",
* and a server that paraphrases its gates would be the most convenient place in
* the system to break it.
*
* node mcp/server.mjs # stdio, for an MCP client
* node mcp/server.mjs --selftest # exercise the handshake and every tool
*/
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, relative } from 'node:path';
import { homedir } from 'node:os';
import { catalogue, byName, KIT } from './gates.mjs';
import { ensure, loadIndex } from '../scripts/library.mjs';
const PROTOCOL = '2025-06-18';
const pkg = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf8'));
/* ------------------------------------------------------------------ tools */
const TOOLS = [
{
name: 'list_gates',
title: 'List the objective gates',
description:
'Every gate this kit can run, what each one measures, and whether it needs a browser. '
+ 'Call this first: the gate names it returns are what run_gate accepts.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'run_gate',
title: 'Run one gate',
description:
'Run a single gate against files or directories and return exactly what it printed, '
+ 'with its exit code. Exit 0 means it looked and found nothing wrong; 1 means it found '
+ 'something; 2 means it could not look - a path that does not exist is never a pass. '
+ 'A render gate with no browser installed FAILS here rather than skipping, so a clean '
+ 'result always means something was measured.',
inputSchema: {
type: 'object',
properties: {
gate: { type: 'string', description: 'A gate name from list_gates, such as verify_states.' },
paths: {
type: 'array', items: { type: 'string' }, minItems: 1,
description: 'Files or directories to measure, absolute or relative to the working directory.',
},
args: {
type: 'array', items: { type: 'string' },
description: 'Extra flags the gate accepts, such as --dark or --scale=1.25.',
},
},
required: ['gate', 'paths'],
additionalProperties: false,
},
},
{
name: 'review_ui',
title: 'Run every applicable gate',
description:
'Run every gate that applies to the given paths and return a per-gate verdict with the '
+ 'full output of each failure. This is the "did I get this right" call. It is all-or-nothing '
+ 'on purpose: there is no partial credit and no score. It measures objective correctness - '
+ 'tokens, accessibility, drift - and says nothing about whether the work is any good.',
inputSchema: {
type: 'object',
properties: {
paths: { type: 'array', items: { type: 'string' }, minItems: 1 },
tokens: { type: 'string', description: 'Optional path to the project\'s token JSON or theme CSS, for the gates that need one.' },
skip_browser: { type: 'boolean', description: 'Run only the gates that read files. Use when no browser is installed; the result then covers less.' },
},
required: ['paths'],
additionalProperties: false,
},
},
{
name: 'get_doctrine',
title: 'Read a rule from the kit',
description:
'The kit\'s written doctrine: the rules an agent should build to before it generates. '
+ 'Topics are tokens, typography, components, accessibility, frameworks, taste, '
+ 'review, brand, and agents (the whole brief). Read the relevant one BEFORE building, '
+ 'not after a gate fails.',
inputSchema: {
type: 'object',
properties: { topic: { type: 'string', description: 'One of the topics named above.' } },
required: ['topic'],
additionalProperties: false,
},
},
{
name: 'get_design_system',
title: 'Read a named design system',
description:
'One of the kit\'s brand-grade design system specs: visual theme, colour roles with hex, '
+ 'typography, spacing, components, motion. Call with no name to list what is available. '
+ 'An npm install fetches a spec the first time it is asked for, checks it against the '
+ 'sha256 the kit ships, and serves it from a local cache after that.',
inputSchema: {
type: 'object',
properties: { name: { type: 'string', description: 'A system name such as stripe or linear. Omit to list them all.' } },
additionalProperties: false,
},
},
{
name: 'get_tokens',
title: 'Read the token source',
description:
'The kit\'s DTCG design tokens - colour, typography, spacing, motion, sizing and the rest - '
+ 'as the starting point for a project theme. Call with no file to list what is available.',
inputSchema: {
type: 'object',
properties: { file: { type: 'string', description: 'A token file such as colors or spacing. Omit to list them all.' } },
additionalProperties: false,
},
},
];
/* --------------------------------------------------------------- handlers */
const text = (s, isError = false) => ({ content: [{ type: 'text', text: s }], isError });
/** Run a gate the way the kit insists gates are run. */
function runGate(gate, paths, extra = []) {
const g = byName(gate);
if (!g) {
return { ok: false, out: `Unknown gate "${gate}". Call list_gates for the names it accepts.`, code: null };
}
const script = join(KIT, 'scripts', g.file);
const r = spawnSync(g.runner, [script, ...paths, ...extra], {
encoding: 'utf8',
timeout: 300000,
// Never optional. A render gate with no browser prints SKIPPED and exits 0,
// and a "pass" from a run that measured nothing is the one failure this
// whole kit is written against.
env: { ...process.env, DS_REQUIRE_BROWSER: '1' },
});
const out = ((r.stdout || '') + (r.stderr || '')).trim();
return { ok: r.status === 0, code: r.status, out, gate: g };
}
const DOCTRINE = {
tokens: '.claude/rules/tokens-and-color.md',
typography: '.claude/rules/typography-and-spacing.md',
components: '.claude/rules/components.md',
accessibility: '.claude/rules/accessibility.md',
frameworks: '.claude/rules/frameworks.md',
review: '.claude/rules/review-and-research.md',
brand: '.claude/rules/brand-and-operations.md',
taste: 'taste/design-taste.md',
agents: 'AGENTS.md',
};
function handle(name, args) {
if (name === 'list_gates') {
const c = catalogue();
const line = (g) => `${g.name}${g.browser ? ' [needs a browser]' : ''}\n ${g.measures}`;
return text(
`${c.length} gates. ${c.filter(g => g.browser).length} open a real browser; `
+ `the rest read files.\n\n${c.map(line).join('\n\n')}\n\n`
+ 'Exit codes: 0 looked and found nothing, 1 found something, 2 could not look.');
}
if (name === 'run_gate') {
const { gate, paths, args: extra = [] } = args;
const missing = paths.filter(p => !existsSync(p));
if (missing.length) {
return text(`Not found: ${missing.join(', ')}\nNothing was measured. This is not a pass.`, true);
}
const r = runGate(gate, paths, extra);
if (r.code === null && !r.gate) return text(r.out, true);
return text(`${r.gate.name} exited ${r.code}\n\n${r.out}`, !r.ok);
}
if (name === 'review_ui') {
const { paths, tokens, skip_browser } = args;
const missing = paths.filter(p => !existsSync(p));
if (missing.length) {
return text(`Not found: ${missing.join(', ')}\nNothing was measured. This is not a pass.`, true);
}
const html = paths.some(p => statSync(p).isDirectory() || /\.html?$/i.test(p));
const results = [];
for (const g of catalogue()) {
if (g.browser && skip_browser) continue;
if (g.browser && !html) continue; // a render gate needs something to render
if (g.name === 'validate_theme_refs' && !tokens) continue; // it needs a theme to check against
if (g.name === 'validate_tokens' || g.name === 'validate_contrast') {
if (!tokens) continue;
results.push({ g, r: runGate(g.name, [tokens]) });
continue;
}
const extra = g.name === 'validate_theme_refs' ? ['--theme', tokens] : [];
results.push({ g, r: runGate(g.name, extra.length ? [...extra, ...paths] : paths) });
}
const failed = results.filter(x => !x.r.ok);
const head = `${results.length - failed.length}/${results.length} gates passed`
+ (skip_browser ? ' (browser gates skipped - this covers less)' : '');
const body = results.map(x => ` ${x.r.ok ? 'PASS' : 'FAIL'} ${x.g.name}`).join('\n');
const detail = failed.length
? '\n\nFailures, in full:\n\n' + failed.map(x => `--- ${x.g.name} (exit ${x.r.code})\n${x.r.out}`).join('\n\n')
: '';
const scope = '\n\nThis is objective correctness only: tokens, accessibility, drift. '
+ 'It does not measure whether the work is any good, and no number here covers taste.';
return text(`${head}\n\n${body}${detail}${scope}`, failed.length > 0);
}
if (name === 'get_doctrine') {
const rel = DOCTRINE[String(args.topic || '').toLowerCase()];
if (!rel) {
return text(`Unknown topic "${args.topic}". Available: ${Object.keys(DOCTRINE).join(', ')}.`, true);
}
const p = join(KIT, rel);
if (!existsSync(p)) return text(`${rel} is missing from this install.`, true);
return text(readFileSync(p, 'utf8'));
}
if (name === 'get_design_system') {
let index;
try { index = loadIndex(KIT); }
catch { return text('The design-system index is not in this install.', true); }
const all = Object.keys(index.systems).sort();
if (!args.name) return text(`${all.length} systems:\n\n${all.join(', ')}`);
const slug = String(args.name);
if (!Object.hasOwn(index.systems, slug)) {
const near = all.filter(n => n.includes(slug.toLowerCase())).slice(0, 8);
return text(`No system "${args.name}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''}`, true);
}
// A clone or a plugin install carries the specs; read them as they are.
const p = join(KIT, 'design-systems/library', slug, 'DESIGN.md');
if (existsSync(p)) return text(readFileSync(p, 'utf8'));
// An npm install does not: fetch once into a cache keyed by the index tag.
const cache = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'ux-ui-agent-skills', index.ref);
return ensure(slug, { kit: KIT, into: cache, repair: true, index })
.then(r => text(r.buf.toString('utf8')))
.catch(e => text(`${slug}: ${e.message}`, true));
}
if (name === 'get_tokens') {
const dir = join(KIT, 'tokens');
if (!existsSync(dir)) return text('The token source is not in this install.', true);
const all = readdirSync(dir).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, '')).sort();
if (!args.file) return text(`${all.length} token files:\n\n${all.join(', ')}`);
const p = join(dir, `${String(args.file).replace(/\.json$/, '')}.json`);
if (!existsSync(p)) return text(`No token file "${args.file}". Available: ${all.join(', ')}.`, true);
return text(readFileSync(p, 'utf8'));
}
return null; // unknown tool: a protocol error, not a tool error
}
/* -------------------------------------------------------------- transport */
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
const pending = new Set();
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
function dispatch(msg) {
const { id, method, params } = msg;
if (method === 'initialize') {
/* The spec: respond with the client's version when supported, otherwise the
latest this server speaks. Echoing a version blindly would claim support
for a protocol this file has never been read against. */
const asked = params?.protocolVersion;
return reply(id, {
protocolVersion: asked === PROTOCOL ? asked : PROTOCOL,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'ux-ui-agent-skills', title: 'UX/UI design system gates', version: pkg.version },
instructions:
'Design-system doctrine and the objective gates that check work against it.\n\n'
+ 'Before building a screen: get_doctrine for the rules that apply, and get_tokens for the '
+ 'token source.\n'
+ 'After building: review_ui on what you produced, and report what it printed.\n\n'
+ 'Never state a contrast ratio, a WCAG verdict or a pass rate that a gate did not just '
+ 'print. If you have not run it, "not verified yet" is the correct answer and is always '
+ 'acceptable. A gate exiting 2 means it could not look - that is not a pass.',
});
}
if (method === 'notifications/initialized' || method?.startsWith('notifications/')) return;
if (method === 'ping') return reply(id, {});
if (method === 'tools/list') return reply(id, { tools: TOOLS });
if (method === 'tools/call') {
const tool = TOOLS.find(t => t.name === params?.name);
if (!tool) return fail(id, -32602, `Unknown tool: ${params?.name}`);
// get_design_system may fetch, so every call is answered once it settles.
const done = Promise.resolve()
.then(() => handle(params.name, params.arguments || {}))
.then(result => result ? reply(id, result) : fail(id, -32602, `Unknown tool: ${params.name}`))
// A thrown error is the tool failing, not the protocol failing.
.catch(e => reply(id, text(`${tool.name} could not run: ${e.message}`, true)))
.finally(() => pending.delete(done));
pending.add(done);
return done;
}
if (id !== undefined) fail(id, -32601, `Method not found: ${method}`);
}
/* ------------------------------------------------------------------- main */
if (process.argv.includes('--selftest')) {
const { selftest } = await import('./selftest.mjs');
process.exit(await selftest(dispatch, TOOLS) ? 0 : 1);
}
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
try { dispatch(JSON.parse(line)); }
catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
}
});
// A client that closes stdin still gets the answers it already asked for.
process.stdin.on('end', () => Promise.allSettled([...pending]).then(() => process.exit(0)));