@mandujs/mcp 0.34.2 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.34.2",
3
+ "version": "0.36.0",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@mandujs/core": "^0.50.0",
37
+ "@mandujs/core": "^0.52.0",
38
38
  "@mandujs/ate": "^0.25.1",
39
39
  "@mandujs/skills": "^0.19.0",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
@@ -0,0 +1,825 @@
1
+ /**
2
+ * MCP design discovery tools — Issue #245 M4 (Team C).
3
+ *
4
+ * Read-mostly tools agents call before / during UI work so they don't
5
+ * have to grep the project, mis-name a component, or invent a token
6
+ * that already exists. The tools share Mandu's `@mandujs/core/design`
7
+ * parser + `@mandujs/core/design/tailwind-theme` compiler so agents
8
+ * and humans see identical output.
9
+ *
10
+ * Tools shipped here:
11
+ *
12
+ * - `mandu.design.get` — DESIGN.md by section (or full spec)
13
+ * - `mandu.design.prompt` — §9 Agent Prompts (pre-warm payload)
14
+ * - `mandu.design.check` — Guard rule preview on a single file
15
+ * - `mandu.component.list` — project component inventory
16
+ *
17
+ * Future write-tools (extract / patch / propose / diff_upstream) per
18
+ * the v2 plan §4.3 are deferred to a follow-up — they need careful UX
19
+ * around user-approval gating that's outside this initial slice.
20
+ */
21
+
22
+ import { promises as fs } from "node:fs";
23
+ import path from "node:path";
24
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
25
+ import {
26
+ diffDesignSpecs,
27
+ extractDesignTokens,
28
+ fetchUpstreamDesignMd,
29
+ parseDesignMd,
30
+ patchDesignMd,
31
+ patchDesignMdBatch,
32
+ type DesignSpec,
33
+ type DesignSectionId,
34
+ type ExtractKind,
35
+ type PatchOperation,
36
+ type PatchableSection,
37
+ DESIGN_SECTION_IDS,
38
+ } from "@mandujs/core/design";
39
+ import { checkFileForDesignInlineClasses } from "@mandujs/core/guard/design-inline-class";
40
+
41
+ // ─── Internal helpers ─────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Best-effort DESIGN.md location resolver. Walks the conventional
45
+ * project root names; returns null when nothing is found so callers
46
+ * can produce a friendly error rather than throw.
47
+ */
48
+ async function readDesignMd(rootDir: string): Promise<{ source: string; path: string } | null> {
49
+ const candidates = ["DESIGN.md", "design.md", "docs/DESIGN.md"];
50
+ for (const rel of candidates) {
51
+ const full = path.join(rootDir, rel);
52
+ try {
53
+ const source = await fs.readFile(full, "utf8");
54
+ return { source, path: full };
55
+ } catch {
56
+ // try next
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ interface GuardConfigForDesign {
63
+ forbidInlineClasses?: string[];
64
+ requireComponent?: Record<string, string>;
65
+ exclude?: string[];
66
+ designMd?: string;
67
+ autoFromDesignMd?: boolean;
68
+ severity?: "warning" | "error";
69
+ }
70
+
71
+ /**
72
+ * Pull `guard.design` out of `mandu.config.ts` (or `.js`). Best-effort
73
+ * — when no config is present we still scan with the bare `auto`
74
+ * setting, so agents working on a fresh project still get useful
75
+ * feedback once DESIGN.md has §7 entries.
76
+ */
77
+ async function loadDesignGuardConfig(
78
+ rootDir: string,
79
+ ): Promise<GuardConfigForDesign | undefined> {
80
+ for (const rel of ["mandu.config.ts", "mandu.config.js", "mandu.config.mjs"]) {
81
+ const full = path.join(rootDir, rel);
82
+ try {
83
+ await fs.access(full);
84
+ } catch {
85
+ continue;
86
+ }
87
+ try {
88
+ // Dynamic import — tolerate config files that don't load cleanly
89
+ // in MCP context (rare).
90
+ const mod = (await import(full)) as { default?: { guard?: { design?: GuardConfigForDesign } } };
91
+ return mod.default?.guard?.design;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ // No config file — return a permissive default so DESIGN.md §7
97
+ // tokens still flow through.
98
+ return { autoFromDesignMd: true };
99
+ }
100
+
101
+ // ─── mandu.design.get ─────────────────────────────────────────────────
102
+
103
+ interface DesignGetInput {
104
+ /** Section to return. Use `"all"` to dump the full structured spec. */
105
+ section?: DesignSectionId | "all";
106
+ /** When true, include `rawBody` markdown alongside structured tokens. */
107
+ include_raw?: boolean;
108
+ }
109
+
110
+ async function designGet(
111
+ rootDir: string,
112
+ input: DesignGetInput,
113
+ ): Promise<unknown> {
114
+ const file = await readDesignMd(rootDir);
115
+ if (!file) {
116
+ return {
117
+ error: "DESIGN.md not found",
118
+ hint: "Run `mandu design init` (optionally with `--from <slug>`) to create one.",
119
+ };
120
+ }
121
+ const spec = parseDesignMd(file.source);
122
+ if (!input.section || input.section === "all") {
123
+ return projectSpec(spec, input.include_raw === true, file.path);
124
+ }
125
+ if (!DESIGN_SECTION_IDS.includes(input.section as DesignSectionId)) {
126
+ return {
127
+ error: `Unknown section "${input.section}"`,
128
+ hint: `Use one of: ${DESIGN_SECTION_IDS.join(", ")}, or "all".`,
129
+ };
130
+ }
131
+ const sec = spec.sections[input.section as DesignSectionId];
132
+ return {
133
+ path: file.path,
134
+ section: input.section,
135
+ present: sec.present,
136
+ headingText: sec.headingText,
137
+ ...projectSection(sec, input.include_raw === true),
138
+ };
139
+ }
140
+
141
+ function projectSpec(spec: DesignSpec, includeRaw: boolean, srcPath: string): unknown {
142
+ return {
143
+ path: srcPath,
144
+ title: spec.title,
145
+ sections: Object.fromEntries(
146
+ DESIGN_SECTION_IDS.map((id) => {
147
+ const sec = spec.sections[id];
148
+ return [
149
+ id,
150
+ {
151
+ present: sec.present,
152
+ headingText: sec.headingText,
153
+ ...projectSection(sec, includeRaw),
154
+ },
155
+ ];
156
+ }),
157
+ ),
158
+ extraSections: spec.extraSections.map((s) => ({ heading: s.heading })),
159
+ };
160
+ }
161
+
162
+ function projectSection(sec: DesignSpec["sections"][DesignSectionId], includeRaw: boolean): Record<string, unknown> {
163
+ const out: Record<string, unknown> = {};
164
+ if ("tokens" in sec) out.tokens = sec.tokens;
165
+ if ("rules" in sec) out.rules = sec.rules;
166
+ if ("breakpoints" in sec) out.breakpoints = sec.breakpoints;
167
+ if ("prompts" in sec) out.prompts = sec.prompts;
168
+ if ("summary" in sec && (sec as { summary?: string }).summary) {
169
+ out.summary = (sec as { summary?: string }).summary;
170
+ }
171
+ if (includeRaw) out.rawBody = sec.rawBody;
172
+ return out;
173
+ }
174
+
175
+ // ─── mandu.design.prompt ─────────────────────────────────────────────
176
+
177
+ async function designPrompt(rootDir: string): Promise<unknown> {
178
+ const file = await readDesignMd(rootDir);
179
+ if (!file) {
180
+ return {
181
+ error: "DESIGN.md not found",
182
+ hint: "Run `mandu design init` first; §9 Agent Prompts unlocks once DESIGN.md exists.",
183
+ };
184
+ }
185
+ const spec = parseDesignMd(file.source);
186
+ const section = spec.sections["agent-prompts"];
187
+ if (!section.present || section.prompts.length === 0) {
188
+ return {
189
+ path: file.path,
190
+ prompts: [],
191
+ hint: "DESIGN.md has no §9 Agent Prompts. Add them so agents pre-warm with the same context every session.",
192
+ };
193
+ }
194
+ return {
195
+ path: file.path,
196
+ prompts: section.prompts,
197
+ };
198
+ }
199
+
200
+ // ─── mandu.design.check ──────────────────────────────────────────────
201
+
202
+ interface DesignCheckInput {
203
+ /** File path (relative to project root, or absolute). */
204
+ file?: string;
205
+ }
206
+
207
+ async function designCheck(
208
+ rootDir: string,
209
+ input: DesignCheckInput,
210
+ ): Promise<unknown> {
211
+ if (!input.file || typeof input.file !== "string") {
212
+ return {
213
+ error: "`file` is required",
214
+ hint: 'Pass a relative or absolute path, e.g. "src/client/widgets/header.tsx".',
215
+ };
216
+ }
217
+ const config = await loadDesignGuardConfig(rootDir);
218
+ if (!config) {
219
+ return {
220
+ file: input.file,
221
+ violations: [],
222
+ note: "No `guard.design` config and no DESIGN.md §7 tokens — nothing to check.",
223
+ };
224
+ }
225
+ const violations = await checkFileForDesignInlineClasses(rootDir, input.file, config);
226
+ return {
227
+ file: input.file,
228
+ violations: violations.map((v) => ({
229
+ line: v.line,
230
+ severity: v.severity,
231
+ rule: v.ruleId,
232
+ message: v.message,
233
+ suggestion: v.suggestion,
234
+ })),
235
+ };
236
+ }
237
+
238
+ // ─── mandu.component.list ────────────────────────────────────────────
239
+
240
+ const COMPONENT_DIRS = [
241
+ { dir: "src/client/shared/ui", category: "ui-primitive" as const },
242
+ { dir: "src/client/widgets", category: "widget" as const },
243
+ ];
244
+
245
+ interface ComponentListInput {
246
+ /** Filter by category. */
247
+ category?: "ui-primitive" | "widget" | "all";
248
+ /**
249
+ * When true, include a count of usages across `src/**` / `app/**`.
250
+ * Default false — usage counting walks the source tree and is the
251
+ * slowest part of the response.
252
+ */
253
+ count_usage?: boolean;
254
+ }
255
+
256
+ interface ComponentEntry {
257
+ name: string;
258
+ category: "ui-primitive" | "widget";
259
+ path: string;
260
+ description?: string;
261
+ props?: string[];
262
+ usage_count?: number;
263
+ }
264
+
265
+ async function componentList(
266
+ rootDir: string,
267
+ input: ComponentListInput,
268
+ ): Promise<unknown> {
269
+ const filter = input.category ?? "all";
270
+ const wantUsage = input.count_usage === true;
271
+ const entries: ComponentEntry[] = [];
272
+
273
+ for (const { dir, category } of COMPONENT_DIRS) {
274
+ if (filter !== "all" && filter !== category) continue;
275
+ const root = path.join(rootDir, dir);
276
+ let files: string[] = [];
277
+ try {
278
+ files = await collectFiles(root);
279
+ } catch {
280
+ continue;
281
+ }
282
+ for (const file of files) {
283
+ const rel = path.relative(rootDir, file).replace(/\\/g, "/");
284
+ let source: string;
285
+ try {
286
+ source = await fs.readFile(file, "utf8");
287
+ } catch {
288
+ continue;
289
+ }
290
+ const components = extractExportedComponents(source);
291
+ for (const c of components) {
292
+ const entry: ComponentEntry = {
293
+ name: c.name,
294
+ category,
295
+ path: rel,
296
+ };
297
+ if (c.description) entry.description = c.description;
298
+ if (c.props.length > 0) entry.props = c.props;
299
+ entries.push(entry);
300
+ }
301
+ }
302
+ }
303
+
304
+ if (wantUsage) {
305
+ await populateUsageCounts(rootDir, entries);
306
+ }
307
+
308
+ return {
309
+ count: entries.length,
310
+ components: entries,
311
+ };
312
+ }
313
+
314
+ async function collectFiles(root: string): Promise<string[]> {
315
+ const out: string[] = [];
316
+ async function walk(dir: string): Promise<void> {
317
+ let names: import("node:fs").Dirent[];
318
+ try {
319
+ names = await fs.readdir(dir, { withFileTypes: true });
320
+ } catch {
321
+ return;
322
+ }
323
+ for (const name of names) {
324
+ const full = path.join(dir, name.name);
325
+ if (name.isDirectory()) {
326
+ await walk(full);
327
+ } else if (
328
+ name.isFile() &&
329
+ /\.(tsx?|jsx?)$/.test(name.name) &&
330
+ !name.name.endsWith(".test.ts") &&
331
+ !name.name.endsWith(".test.tsx")
332
+ ) {
333
+ out.push(full);
334
+ }
335
+ }
336
+ }
337
+ await walk(root);
338
+ return out;
339
+ }
340
+
341
+ interface ExtractedComponent {
342
+ name: string;
343
+ description?: string;
344
+ props: string[];
345
+ }
346
+
347
+ const EXPORT_FN_RX = /export\s+function\s+([A-Z]\w*)\s*\(/g;
348
+ const EXPORT_CONST_RX = /export\s+const\s+([A-Z]\w*)\s*[:=]/g;
349
+
350
+ function extractExportedComponents(source: string): ExtractedComponent[] {
351
+ const out: ExtractedComponent[] = [];
352
+ const seen = new Set<string>();
353
+ // First-pass JSDoc extraction is best-effort — captures the line
354
+ // immediately above the export when wrapped in `/** ... */`.
355
+ const jsdocAbove = (offset: number): string | undefined => {
356
+ const before = source.slice(0, offset);
357
+ const m = /\/\*\*([\s\S]*?)\*\/\s*$/m.exec(before);
358
+ if (!m) return undefined;
359
+ const body = m[1]!.split("\n")
360
+ .map((l) => l.replace(/^\s*\*\s?/, "").trim())
361
+ .filter(Boolean);
362
+ return body[0];
363
+ };
364
+ for (const re of [EXPORT_FN_RX, EXPORT_CONST_RX]) {
365
+ re.lastIndex = 0;
366
+ let m: RegExpExecArray | null;
367
+ while ((m = re.exec(source)) !== null) {
368
+ const name = m[1]!;
369
+ if (seen.has(name)) continue;
370
+ seen.add(name);
371
+ const description = jsdocAbove(m.index);
372
+ out.push({
373
+ name,
374
+ description,
375
+ props: extractPropsForComponent(source, name),
376
+ });
377
+ }
378
+ }
379
+ return out;
380
+ }
381
+
382
+ function extractPropsForComponent(source: string, name: string): string[] {
383
+ // Look for the first `interface <Name>Props { ... }` or `type
384
+ // <Name>Props = { ... }` declaration in the same file. Captures the
385
+ // top-level identifier-style keys; gives up cleanly when the type is
386
+ // unusual.
387
+ const interfaceRe = new RegExp(
388
+ `(?:interface|type)\\s+${name}Props[^\\{]*\\{([\\s\\S]*?)\\}`,
389
+ "m",
390
+ );
391
+ const m = interfaceRe.exec(source);
392
+ if (!m) return [];
393
+ const body = m[1]!;
394
+ const propRe = /^\s*([A-Za-z_$][\w$]*)\??\s*:/gm;
395
+ const props: string[] = [];
396
+ let pm: RegExpExecArray | null;
397
+ while ((pm = propRe.exec(body)) !== null) {
398
+ props.push(pm[1]!);
399
+ }
400
+ return props;
401
+ }
402
+
403
+ async function populateUsageCounts(rootDir: string, entries: ComponentEntry[]): Promise<void> {
404
+ const targets = ["src", "app"];
405
+ const filesByDir: Map<string, string[]> = new Map();
406
+ for (const t of targets) {
407
+ try {
408
+ filesByDir.set(t, await collectFiles(path.join(rootDir, t)));
409
+ } catch {
410
+ // skip
411
+ }
412
+ }
413
+ for (const entry of entries) {
414
+ let count = 0;
415
+ const ident = new RegExp(`\\b${escapeRegex(entry.name)}\\b`);
416
+ for (const files of filesByDir.values()) {
417
+ for (const file of files) {
418
+ try {
419
+ const source = await fs.readFile(file, "utf8");
420
+ if (ident.test(source)) count++;
421
+ } catch {
422
+ // skip
423
+ }
424
+ }
425
+ }
426
+ entry.usage_count = count;
427
+ }
428
+ }
429
+
430
+ function escapeRegex(s: string): string {
431
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
432
+ }
433
+
434
+ // ─── mandu.design.extract (write tool — read-only, returns proposals)
435
+
436
+ interface DesignExtractInput {
437
+ scope?: string[];
438
+ kinds?: ExtractKind[];
439
+ min_occurrences?: number;
440
+ }
441
+
442
+ async function designExtract(rootDir: string, input: DesignExtractInput): Promise<unknown> {
443
+ const file = await readDesignMd(rootDir);
444
+ const existing = file ? parseDesignMd(file.source) : undefined;
445
+ const result = await extractDesignTokens(rootDir, {
446
+ scope: input.scope,
447
+ kinds: input.kinds,
448
+ minOccurrences: input.min_occurrences,
449
+ existing,
450
+ });
451
+ return {
452
+ scanned_files: result.scannedFiles,
453
+ proposals: result.proposals.map((p) => ({
454
+ kind: p.kind,
455
+ section: p.section,
456
+ key: p.key,
457
+ value: p.value,
458
+ occurrences: p.occurrences,
459
+ files: p.files,
460
+ confidence: p.confidence,
461
+ })),
462
+ };
463
+ }
464
+
465
+ // ─── mandu.design.patch ──────────────────────────────────────────────
466
+
467
+ interface DesignPatchInput {
468
+ /** Single op shorthand. */
469
+ section?: PatchableSection;
470
+ operation?: PatchOperation["operation"];
471
+ key?: string;
472
+ value?: string;
473
+ role?: string;
474
+ /** Batch — overrides single-op fields when present. */
475
+ ops?: PatchOperation[];
476
+ /** Default true — return diff without writing. */
477
+ dry_run?: boolean;
478
+ }
479
+
480
+ async function designPatch(rootDir: string, input: DesignPatchInput): Promise<unknown> {
481
+ const file = await readDesignMd(rootDir);
482
+ if (!file) {
483
+ return {
484
+ error: "DESIGN.md not found",
485
+ hint: "Run `mandu design init` first.",
486
+ };
487
+ }
488
+ const ops: PatchOperation[] = input.ops ?? (
489
+ input.section && input.operation && input.key
490
+ ? [{
491
+ section: input.section,
492
+ operation: input.operation,
493
+ key: input.key,
494
+ value: input.value,
495
+ role: input.role,
496
+ }]
497
+ : []
498
+ );
499
+ if (ops.length === 0) {
500
+ return {
501
+ error: "No operations supplied",
502
+ hint: "Pass either { section, operation, key, value? } or `ops: [...]`.",
503
+ };
504
+ }
505
+ const batch = patchDesignMdBatch(file.source, ops);
506
+ const dryRun = input.dry_run !== false;
507
+ if (!dryRun && batch.appliedCount > 0) {
508
+ await fs.writeFile(file.path, batch.next, "utf8");
509
+ }
510
+ return {
511
+ path: file.path,
512
+ dry_run: dryRun,
513
+ applied_count: batch.appliedCount,
514
+ written: !dryRun && batch.appliedCount > 0,
515
+ results: batch.results.map((r, i) => ({
516
+ op: ops[i],
517
+ applied: r.applied,
518
+ reason: r.reason,
519
+ before: r.before,
520
+ after: r.after,
521
+ })),
522
+ next_source: dryRun ? batch.next : undefined,
523
+ };
524
+ }
525
+
526
+ // ─── mandu.design.propose (extract + dry-run patch in one call) ──────
527
+
528
+ interface DesignProposeInput {
529
+ scope?: string[];
530
+ kinds?: ExtractKind[];
531
+ min_occurrences?: number;
532
+ /** Default true — never writes; returns proposals + dry-run patches. */
533
+ dry_run?: boolean;
534
+ }
535
+
536
+ async function designPropose(rootDir: string, input: DesignProposeInput): Promise<unknown> {
537
+ const extractResult = await designExtract(rootDir, input) as {
538
+ scanned_files: number;
539
+ proposals: Array<{ section: PatchableSection; key: string; value: string; occurrences: number; confidence: number; files: string[]; kind: string }>;
540
+ };
541
+ if ("error" in extractResult) return extractResult;
542
+
543
+ const file = await readDesignMd(rootDir);
544
+ if (!file) {
545
+ return {
546
+ ...extractResult,
547
+ error: "DESIGN.md not found — proposals cannot be patched in. Run `mandu design init` first.",
548
+ };
549
+ }
550
+
551
+ // Convert top proposals (color/typography/layout/shadows only — not
552
+ // components, which need richer human input) into add operations.
553
+ const ops: PatchOperation[] = extractResult.proposals
554
+ .filter((p) => p.section !== "components")
555
+ .map((p) => ({
556
+ section: p.section,
557
+ operation: "add" as const,
558
+ key: synthesiseKeyForProposal(p),
559
+ value: p.value,
560
+ }));
561
+ const batch = patchDesignMdBatch(file.source, ops);
562
+ const dryRun = input.dry_run !== false;
563
+ if (!dryRun && batch.appliedCount > 0) {
564
+ await fs.writeFile(file.path, batch.next, "utf8");
565
+ }
566
+ return {
567
+ ...extractResult,
568
+ path: file.path,
569
+ dry_run: dryRun,
570
+ applied_count: batch.appliedCount,
571
+ written: !dryRun && batch.appliedCount > 0,
572
+ patch_results: batch.results.map((r, i) => ({
573
+ op: ops[i],
574
+ applied: r.applied,
575
+ reason: r.reason,
576
+ })),
577
+ };
578
+ }
579
+
580
+ function synthesiseKeyForProposal(p: { kind: string; key: string }): string {
581
+ // Color literals like "#ff8c42" → "Orange-FF8C42" — give the user
582
+ // something readable they can rename in the next patch round.
583
+ if (p.kind === "color") {
584
+ const stripped = p.key.replace(/^#/, "").toUpperCase();
585
+ return `Color-${stripped}`;
586
+ }
587
+ if (p.kind === "typography") {
588
+ const head = p.key.split(/[,\s]/)[0] ?? p.key;
589
+ return head.replace(/[`'"]/g, "");
590
+ }
591
+ return p.key;
592
+ }
593
+
594
+ // ─── mandu.design.diff_upstream ──────────────────────────────────────
595
+
596
+ interface DesignDiffUpstreamInput {
597
+ /** awesome-design-md slug or any raw URL. Required. */
598
+ slug?: string;
599
+ }
600
+
601
+ async function designDiffUpstream(rootDir: string, input: DesignDiffUpstreamInput): Promise<unknown> {
602
+ if (!input.slug) {
603
+ return {
604
+ error: "`slug` is required",
605
+ hint: 'Pass an awesome-design-md slug (e.g. "stripe") or a raw URL.',
606
+ };
607
+ }
608
+ const file = await readDesignMd(rootDir);
609
+ if (!file) {
610
+ return {
611
+ error: "Local DESIGN.md not found",
612
+ hint: "Run `mandu design init --from <slug>` first.",
613
+ };
614
+ }
615
+ const local = parseDesignMd(file.source);
616
+ let upstreamSource: string;
617
+ try {
618
+ upstreamSource = await fetchUpstreamDesignMd(input.slug);
619
+ } catch (err) {
620
+ return {
621
+ error: `Failed to fetch upstream "${input.slug}": ${err instanceof Error ? err.message : String(err)}`,
622
+ hint: "Check your network or the slug; see github.com/VoltAgent/awesome-design-md for valid slugs.",
623
+ };
624
+ }
625
+ const upstream = parseDesignMd(upstreamSource);
626
+ const diff = diffDesignSpecs(local, upstream);
627
+ return {
628
+ slug: input.slug,
629
+ local_path: file.path,
630
+ total_changes: diff.totalChanges,
631
+ section_presence_changed: diff.sectionPresenceChanged,
632
+ color_palette: diff.colorPalette,
633
+ typography: diff.typography,
634
+ layout: diff.layout,
635
+ shadows: diff.shadows,
636
+ };
637
+ }
638
+
639
+ // ─── MCP definitions + handlers ───────────────────────────────────────
640
+
641
+ export const designToolDefinitions: Tool[] = [
642
+ {
643
+ name: "mandu.design.get",
644
+ description:
645
+ "Read structured tokens from the project's DESIGN.md. Pass `section: 'color-palette'` (or `'typography'`/`'components'`/...) for one slice, or `'all'` (default) for the full parsed spec. `include_raw: true` returns the original markdown bodies alongside structured tokens.",
646
+ annotations: { readOnlyHint: true },
647
+ inputSchema: {
648
+ type: "object",
649
+ properties: {
650
+ section: {
651
+ type: "string",
652
+ enum: [...DESIGN_SECTION_IDS, "all"],
653
+ description: "Section id to return; `all` dumps the full spec.",
654
+ },
655
+ include_raw: {
656
+ type: "boolean",
657
+ description:
658
+ "Include raw markdown body alongside structured tokens. Default false.",
659
+ },
660
+ },
661
+ required: [],
662
+ },
663
+ },
664
+ {
665
+ name: "mandu.design.prompt",
666
+ description:
667
+ "Return DESIGN.md §9 Agent Prompts — pre-warm payload an agent reads before starting UI work. Empty array + hint when the section is unpopulated.",
668
+ annotations: { readOnlyHint: true },
669
+ inputSchema: {
670
+ type: "object",
671
+ properties: {},
672
+ required: [],
673
+ },
674
+ },
675
+ {
676
+ name: "mandu.design.check",
677
+ description:
678
+ "Run the DESIGN_INLINE_CLASS Guard rule on a single file before editing it. Returns a list of violations with line, message, and `requireComponent` suggestion. Reads `mandu.config.ts > guard.design` (or DESIGN.md §7 when `autoFromDesignMd: true`).",
679
+ annotations: { readOnlyHint: true },
680
+ inputSchema: {
681
+ type: "object",
682
+ properties: {
683
+ file: {
684
+ type: "string",
685
+ description:
686
+ "File to check. Relative paths resolve against the project root; absolute paths are accepted as-is.",
687
+ },
688
+ },
689
+ required: ["file"],
690
+ },
691
+ },
692
+ {
693
+ name: "mandu.component.list",
694
+ description:
695
+ "Inventory the project's components. Walks `src/client/shared/ui/` (ui-primitive) and `src/client/widgets/` (widget) for exported React components, with optional usage counts via `count_usage: true`.",
696
+ annotations: { readOnlyHint: true },
697
+ inputSchema: {
698
+ type: "object",
699
+ properties: {
700
+ category: {
701
+ type: "string",
702
+ enum: ["ui-primitive", "widget", "all"],
703
+ description: "Filter by category. Defaults to `all`.",
704
+ },
705
+ count_usage: {
706
+ type: "boolean",
707
+ description:
708
+ "When true, count usages across src/** and app/**. Slower; default false.",
709
+ },
710
+ },
711
+ required: [],
712
+ },
713
+ },
714
+ {
715
+ name: "mandu.design.extract",
716
+ description:
717
+ "Scan src/** + app/** for token candidates (color literals, font-families, repeated className combos) and return proposals to patch into DESIGN.md. Read-only — does not modify any file. Filters out tokens already represented in DESIGN.md.",
718
+ annotations: { readOnlyHint: true },
719
+ inputSchema: {
720
+ type: "object",
721
+ properties: {
722
+ scope: {
723
+ type: "array",
724
+ items: { type: "string" },
725
+ description: "Project-relative roots to scan. Default ['src', 'app'].",
726
+ },
727
+ kinds: {
728
+ type: "array",
729
+ items: { type: "string", enum: ["color", "typography", "spacing", "component"] },
730
+ description: "Restrict the proposal kinds. Default: all four.",
731
+ },
732
+ min_occurrences: {
733
+ type: "number",
734
+ description: "Minimum occurrence threshold. Default 3.",
735
+ },
736
+ },
737
+ required: [],
738
+ },
739
+ },
740
+ {
741
+ name: "mandu.design.patch",
742
+ description:
743
+ "Section-safe DESIGN.md patcher (add / update / remove a token). Defaults to dry-run — pass `dry_run: false` to actually rewrite the file. Pass either single-op fields (`section`, `operation`, `key`, `value?`, `role?`) or a batch via `ops: [...]`.",
744
+ annotations: { readOnlyHint: false },
745
+ inputSchema: {
746
+ type: "object",
747
+ properties: {
748
+ section: {
749
+ type: "string",
750
+ enum: ["color-palette", "typography", "layout", "shadows", "components"],
751
+ },
752
+ operation: { type: "string", enum: ["add", "update", "remove"] },
753
+ key: { type: "string" },
754
+ value: { type: "string" },
755
+ role: { type: "string" },
756
+ ops: {
757
+ type: "array",
758
+ description: "Batch — overrides single-op fields.",
759
+ },
760
+ dry_run: {
761
+ type: "boolean",
762
+ description: "When true (default), return the diff without writing.",
763
+ },
764
+ },
765
+ required: [],
766
+ },
767
+ },
768
+ {
769
+ name: "mandu.design.propose",
770
+ description:
771
+ "Combined extract + dry-run patch. Runs `mandu.design.extract`, converts color / typography / layout / shadow proposals into add operations, and returns both the proposal list and the patch results. Always dry-run by default; pass `dry_run: false` to apply.",
772
+ annotations: { readOnlyHint: false },
773
+ inputSchema: {
774
+ type: "object",
775
+ properties: {
776
+ scope: { type: "array", items: { type: "string" } },
777
+ kinds: {
778
+ type: "array",
779
+ items: { type: "string", enum: ["color", "typography", "spacing", "component"] },
780
+ },
781
+ min_occurrences: { type: "number" },
782
+ dry_run: {
783
+ type: "boolean",
784
+ description: "Default true (preview only).",
785
+ },
786
+ },
787
+ required: [],
788
+ },
789
+ },
790
+ {
791
+ name: "mandu.design.diff_upstream",
792
+ description:
793
+ "Compare the local DESIGN.md against an upstream brand spec from awesome-design-md. Returns per-section added / changed / removed entries plus a `total_changes` counter and `section_presence_changed` list. Read-only; produces a diff the caller can patch in selectively via `mandu.design.patch`.",
794
+ annotations: { readOnlyHint: true },
795
+ inputSchema: {
796
+ type: "object",
797
+ properties: {
798
+ slug: {
799
+ type: "string",
800
+ description: "awesome-design-md slug (e.g. 'stripe') or a raw DESIGN.md URL.",
801
+ },
802
+ },
803
+ required: ["slug"],
804
+ },
805
+ },
806
+ ];
807
+
808
+ export function designTools(projectRoot: string) {
809
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
810
+ "mandu.design.get": async (args) => designGet(projectRoot, args as DesignGetInput),
811
+ "mandu.design.prompt": async () => designPrompt(projectRoot),
812
+ "mandu.design.check": async (args) => designCheck(projectRoot, args as DesignCheckInput),
813
+ "mandu.component.list": async (args) =>
814
+ componentList(projectRoot, args as ComponentListInput),
815
+ "mandu.design.extract": async (args) =>
816
+ designExtract(projectRoot, args as DesignExtractInput),
817
+ "mandu.design.patch": async (args) =>
818
+ designPatch(projectRoot, args as DesignPatchInput),
819
+ "mandu.design.propose": async (args) =>
820
+ designPropose(projectRoot, args as DesignProposeInput),
821
+ "mandu.design.diff_upstream": async (args) =>
822
+ designDiffUpstream(projectRoot, args as DesignDiffUpstreamInput),
823
+ };
824
+ return handlers;
825
+ }
@@ -64,6 +64,7 @@ export { compositeTools, compositeToolDefinitions } from "./composite.js";
64
64
  export { runTestsTools, runTestsToolDefinitions } from "./run-tests.js";
65
65
  export { deployPreviewTools, deployPreviewToolDefinitions } from "./deploy-preview.js";
66
66
  export { deployPlanTools, deployPlanToolDefinitions } from "./deploy-plan.js";
67
+ export { designTools, designToolDefinitions } from "./design.js";
67
68
  export { aiBriefTools, aiBriefToolDefinitions } from "./ai-brief.js";
68
69
  export { loopCloseTools, loopCloseToolDefinitions } from "./loop-close.js";
69
70
  // #243 — docs search/get for agents grounding answers in real framework docs
@@ -139,6 +140,7 @@ import { compositeTools, compositeToolDefinitions } from "./composite.js";
139
140
  import { runTestsTools, runTestsToolDefinitions } from "./run-tests.js";
140
141
  import { deployPreviewTools, deployPreviewToolDefinitions } from "./deploy-preview.js";
141
142
  import { deployPlanTools, deployPlanToolDefinitions } from "./deploy-plan.js";
143
+ import { designTools, designToolDefinitions } from "./design.js";
142
144
  import { aiBriefTools, aiBriefToolDefinitions } from "./ai-brief.js";
143
145
  import { loopCloseTools, loopCloseToolDefinitions } from "./loop-close.js";
144
146
  import { docsTools, docsToolDefinitions } from "./docs.js";
@@ -258,6 +260,8 @@ const TOOL_MODULES: ToolModule[] = [
258
260
  { category: "deploy-preview", definitions: deployPreviewToolDefinitions, handlers: deployPreviewTools },
259
261
  // #250 — DeployIntent inspection / compile (Phase 1)
260
262
  { category: "deploy-plan", definitions: deployPlanToolDefinitions, handlers: deployPlanTools },
263
+ // #245 M4 — Design system discovery (DESIGN.md / Guard / component inventory)
264
+ { category: "design", definitions: designToolDefinitions, handlers: designTools },
261
265
  { category: "ai-brief", definitions: aiBriefToolDefinitions, handlers: aiBriefTools },
262
266
  { category: "loop-close", definitions: loopCloseToolDefinitions, handlers: loopCloseTools },
263
267
  { category: "docs", definitions: docsToolDefinitions, handlers: docsTools },