@mandujs/mcp 0.34.1 → 0.35.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.1",
3
+ "version": "0.35.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.49.0",
37
+ "@mandujs/core": "^0.51.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,512 @@
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
+ parseDesignMd,
27
+ type DesignSpec,
28
+ type DesignSectionId,
29
+ DESIGN_SECTION_IDS,
30
+ } from "@mandujs/core/design";
31
+ import { checkFileForDesignInlineClasses } from "@mandujs/core/guard/design-inline-class";
32
+
33
+ // ─── Internal helpers ─────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Best-effort DESIGN.md location resolver. Walks the conventional
37
+ * project root names; returns null when nothing is found so callers
38
+ * can produce a friendly error rather than throw.
39
+ */
40
+ async function readDesignMd(rootDir: string): Promise<{ source: string; path: string } | null> {
41
+ const candidates = ["DESIGN.md", "design.md", "docs/DESIGN.md"];
42
+ for (const rel of candidates) {
43
+ const full = path.join(rootDir, rel);
44
+ try {
45
+ const source = await fs.readFile(full, "utf8");
46
+ return { source, path: full };
47
+ } catch {
48
+ // try next
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ interface GuardConfigForDesign {
55
+ forbidInlineClasses?: string[];
56
+ requireComponent?: Record<string, string>;
57
+ exclude?: string[];
58
+ designMd?: string;
59
+ autoFromDesignMd?: boolean;
60
+ severity?: "warning" | "error";
61
+ }
62
+
63
+ /**
64
+ * Pull `guard.design` out of `mandu.config.ts` (or `.js`). Best-effort
65
+ * — when no config is present we still scan with the bare `auto`
66
+ * setting, so agents working on a fresh project still get useful
67
+ * feedback once DESIGN.md has §7 entries.
68
+ */
69
+ async function loadDesignGuardConfig(
70
+ rootDir: string,
71
+ ): Promise<GuardConfigForDesign | undefined> {
72
+ for (const rel of ["mandu.config.ts", "mandu.config.js", "mandu.config.mjs"]) {
73
+ const full = path.join(rootDir, rel);
74
+ try {
75
+ await fs.access(full);
76
+ } catch {
77
+ continue;
78
+ }
79
+ try {
80
+ // Dynamic import — tolerate config files that don't load cleanly
81
+ // in MCP context (rare).
82
+ const mod = (await import(full)) as { default?: { guard?: { design?: GuardConfigForDesign } } };
83
+ return mod.default?.guard?.design;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ // No config file — return a permissive default so DESIGN.md §7
89
+ // tokens still flow through.
90
+ return { autoFromDesignMd: true };
91
+ }
92
+
93
+ // ─── mandu.design.get ─────────────────────────────────────────────────
94
+
95
+ interface DesignGetInput {
96
+ /** Section to return. Use `"all"` to dump the full structured spec. */
97
+ section?: DesignSectionId | "all";
98
+ /** When true, include `rawBody` markdown alongside structured tokens. */
99
+ include_raw?: boolean;
100
+ }
101
+
102
+ async function designGet(
103
+ rootDir: string,
104
+ input: DesignGetInput,
105
+ ): Promise<unknown> {
106
+ const file = await readDesignMd(rootDir);
107
+ if (!file) {
108
+ return {
109
+ error: "DESIGN.md not found",
110
+ hint: "Run `mandu design init` (optionally with `--from <slug>`) to create one.",
111
+ };
112
+ }
113
+ const spec = parseDesignMd(file.source);
114
+ if (!input.section || input.section === "all") {
115
+ return projectSpec(spec, input.include_raw === true, file.path);
116
+ }
117
+ if (!DESIGN_SECTION_IDS.includes(input.section as DesignSectionId)) {
118
+ return {
119
+ error: `Unknown section "${input.section}"`,
120
+ hint: `Use one of: ${DESIGN_SECTION_IDS.join(", ")}, or "all".`,
121
+ };
122
+ }
123
+ const sec = spec.sections[input.section as DesignSectionId];
124
+ return {
125
+ path: file.path,
126
+ section: input.section,
127
+ present: sec.present,
128
+ headingText: sec.headingText,
129
+ ...projectSection(sec, input.include_raw === true),
130
+ };
131
+ }
132
+
133
+ function projectSpec(spec: DesignSpec, includeRaw: boolean, srcPath: string): unknown {
134
+ return {
135
+ path: srcPath,
136
+ title: spec.title,
137
+ sections: Object.fromEntries(
138
+ DESIGN_SECTION_IDS.map((id) => {
139
+ const sec = spec.sections[id];
140
+ return [
141
+ id,
142
+ {
143
+ present: sec.present,
144
+ headingText: sec.headingText,
145
+ ...projectSection(sec, includeRaw),
146
+ },
147
+ ];
148
+ }),
149
+ ),
150
+ extraSections: spec.extraSections.map((s) => ({ heading: s.heading })),
151
+ };
152
+ }
153
+
154
+ function projectSection(sec: DesignSpec["sections"][DesignSectionId], includeRaw: boolean): Record<string, unknown> {
155
+ const out: Record<string, unknown> = {};
156
+ if ("tokens" in sec) out.tokens = sec.tokens;
157
+ if ("rules" in sec) out.rules = sec.rules;
158
+ if ("breakpoints" in sec) out.breakpoints = sec.breakpoints;
159
+ if ("prompts" in sec) out.prompts = sec.prompts;
160
+ if ("summary" in sec && (sec as { summary?: string }).summary) {
161
+ out.summary = (sec as { summary?: string }).summary;
162
+ }
163
+ if (includeRaw) out.rawBody = sec.rawBody;
164
+ return out;
165
+ }
166
+
167
+ // ─── mandu.design.prompt ─────────────────────────────────────────────
168
+
169
+ async function designPrompt(rootDir: string): Promise<unknown> {
170
+ const file = await readDesignMd(rootDir);
171
+ if (!file) {
172
+ return {
173
+ error: "DESIGN.md not found",
174
+ hint: "Run `mandu design init` first; §9 Agent Prompts unlocks once DESIGN.md exists.",
175
+ };
176
+ }
177
+ const spec = parseDesignMd(file.source);
178
+ const section = spec.sections["agent-prompts"];
179
+ if (!section.present || section.prompts.length === 0) {
180
+ return {
181
+ path: file.path,
182
+ prompts: [],
183
+ hint: "DESIGN.md has no §9 Agent Prompts. Add them so agents pre-warm with the same context every session.",
184
+ };
185
+ }
186
+ return {
187
+ path: file.path,
188
+ prompts: section.prompts,
189
+ };
190
+ }
191
+
192
+ // ─── mandu.design.check ──────────────────────────────────────────────
193
+
194
+ interface DesignCheckInput {
195
+ /** File path (relative to project root, or absolute). */
196
+ file?: string;
197
+ }
198
+
199
+ async function designCheck(
200
+ rootDir: string,
201
+ input: DesignCheckInput,
202
+ ): Promise<unknown> {
203
+ if (!input.file || typeof input.file !== "string") {
204
+ return {
205
+ error: "`file` is required",
206
+ hint: 'Pass a relative or absolute path, e.g. "src/client/widgets/header.tsx".',
207
+ };
208
+ }
209
+ const config = await loadDesignGuardConfig(rootDir);
210
+ if (!config) {
211
+ return {
212
+ file: input.file,
213
+ violations: [],
214
+ note: "No `guard.design` config and no DESIGN.md §7 tokens — nothing to check.",
215
+ };
216
+ }
217
+ const violations = await checkFileForDesignInlineClasses(rootDir, input.file, config);
218
+ return {
219
+ file: input.file,
220
+ violations: violations.map((v) => ({
221
+ line: v.line,
222
+ severity: v.severity,
223
+ rule: v.ruleId,
224
+ message: v.message,
225
+ suggestion: v.suggestion,
226
+ })),
227
+ };
228
+ }
229
+
230
+ // ─── mandu.component.list ────────────────────────────────────────────
231
+
232
+ const COMPONENT_DIRS = [
233
+ { dir: "src/client/shared/ui", category: "ui-primitive" as const },
234
+ { dir: "src/client/widgets", category: "widget" as const },
235
+ ];
236
+
237
+ interface ComponentListInput {
238
+ /** Filter by category. */
239
+ category?: "ui-primitive" | "widget" | "all";
240
+ /**
241
+ * When true, include a count of usages across `src/**` / `app/**`.
242
+ * Default false — usage counting walks the source tree and is the
243
+ * slowest part of the response.
244
+ */
245
+ count_usage?: boolean;
246
+ }
247
+
248
+ interface ComponentEntry {
249
+ name: string;
250
+ category: "ui-primitive" | "widget";
251
+ path: string;
252
+ description?: string;
253
+ props?: string[];
254
+ usage_count?: number;
255
+ }
256
+
257
+ async function componentList(
258
+ rootDir: string,
259
+ input: ComponentListInput,
260
+ ): Promise<unknown> {
261
+ const filter = input.category ?? "all";
262
+ const wantUsage = input.count_usage === true;
263
+ const entries: ComponentEntry[] = [];
264
+
265
+ for (const { dir, category } of COMPONENT_DIRS) {
266
+ if (filter !== "all" && filter !== category) continue;
267
+ const root = path.join(rootDir, dir);
268
+ let files: string[] = [];
269
+ try {
270
+ files = await collectFiles(root);
271
+ } catch {
272
+ continue;
273
+ }
274
+ for (const file of files) {
275
+ const rel = path.relative(rootDir, file).replace(/\\/g, "/");
276
+ let source: string;
277
+ try {
278
+ source = await fs.readFile(file, "utf8");
279
+ } catch {
280
+ continue;
281
+ }
282
+ const components = extractExportedComponents(source);
283
+ for (const c of components) {
284
+ const entry: ComponentEntry = {
285
+ name: c.name,
286
+ category,
287
+ path: rel,
288
+ };
289
+ if (c.description) entry.description = c.description;
290
+ if (c.props.length > 0) entry.props = c.props;
291
+ entries.push(entry);
292
+ }
293
+ }
294
+ }
295
+
296
+ if (wantUsage) {
297
+ await populateUsageCounts(rootDir, entries);
298
+ }
299
+
300
+ return {
301
+ count: entries.length,
302
+ components: entries,
303
+ };
304
+ }
305
+
306
+ async function collectFiles(root: string): Promise<string[]> {
307
+ const out: string[] = [];
308
+ async function walk(dir: string): Promise<void> {
309
+ let names: import("node:fs").Dirent[];
310
+ try {
311
+ names = await fs.readdir(dir, { withFileTypes: true });
312
+ } catch {
313
+ return;
314
+ }
315
+ for (const name of names) {
316
+ const full = path.join(dir, name.name);
317
+ if (name.isDirectory()) {
318
+ await walk(full);
319
+ } else if (
320
+ name.isFile() &&
321
+ /\.(tsx?|jsx?)$/.test(name.name) &&
322
+ !name.name.endsWith(".test.ts") &&
323
+ !name.name.endsWith(".test.tsx")
324
+ ) {
325
+ out.push(full);
326
+ }
327
+ }
328
+ }
329
+ await walk(root);
330
+ return out;
331
+ }
332
+
333
+ interface ExtractedComponent {
334
+ name: string;
335
+ description?: string;
336
+ props: string[];
337
+ }
338
+
339
+ const EXPORT_FN_RX = /export\s+function\s+([A-Z]\w*)\s*\(/g;
340
+ const EXPORT_CONST_RX = /export\s+const\s+([A-Z]\w*)\s*[:=]/g;
341
+
342
+ function extractExportedComponents(source: string): ExtractedComponent[] {
343
+ const out: ExtractedComponent[] = [];
344
+ const seen = new Set<string>();
345
+ // First-pass JSDoc extraction is best-effort — captures the line
346
+ // immediately above the export when wrapped in `/** ... */`.
347
+ const jsdocAbove = (offset: number): string | undefined => {
348
+ const before = source.slice(0, offset);
349
+ const m = /\/\*\*([\s\S]*?)\*\/\s*$/m.exec(before);
350
+ if (!m) return undefined;
351
+ const body = m[1]!.split("\n")
352
+ .map((l) => l.replace(/^\s*\*\s?/, "").trim())
353
+ .filter(Boolean);
354
+ return body[0];
355
+ };
356
+ for (const re of [EXPORT_FN_RX, EXPORT_CONST_RX]) {
357
+ re.lastIndex = 0;
358
+ let m: RegExpExecArray | null;
359
+ while ((m = re.exec(source)) !== null) {
360
+ const name = m[1]!;
361
+ if (seen.has(name)) continue;
362
+ seen.add(name);
363
+ const description = jsdocAbove(m.index);
364
+ out.push({
365
+ name,
366
+ description,
367
+ props: extractPropsForComponent(source, name),
368
+ });
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+
374
+ function extractPropsForComponent(source: string, name: string): string[] {
375
+ // Look for the first `interface <Name>Props { ... }` or `type
376
+ // <Name>Props = { ... }` declaration in the same file. Captures the
377
+ // top-level identifier-style keys; gives up cleanly when the type is
378
+ // unusual.
379
+ const interfaceRe = new RegExp(
380
+ `(?:interface|type)\\s+${name}Props[^\\{]*\\{([\\s\\S]*?)\\}`,
381
+ "m",
382
+ );
383
+ const m = interfaceRe.exec(source);
384
+ if (!m) return [];
385
+ const body = m[1]!;
386
+ const propRe = /^\s*([A-Za-z_$][\w$]*)\??\s*:/gm;
387
+ const props: string[] = [];
388
+ let pm: RegExpExecArray | null;
389
+ while ((pm = propRe.exec(body)) !== null) {
390
+ props.push(pm[1]!);
391
+ }
392
+ return props;
393
+ }
394
+
395
+ async function populateUsageCounts(rootDir: string, entries: ComponentEntry[]): Promise<void> {
396
+ const targets = ["src", "app"];
397
+ const filesByDir: Map<string, string[]> = new Map();
398
+ for (const t of targets) {
399
+ try {
400
+ filesByDir.set(t, await collectFiles(path.join(rootDir, t)));
401
+ } catch {
402
+ // skip
403
+ }
404
+ }
405
+ for (const entry of entries) {
406
+ let count = 0;
407
+ const ident = new RegExp(`\\b${escapeRegex(entry.name)}\\b`);
408
+ for (const files of filesByDir.values()) {
409
+ for (const file of files) {
410
+ try {
411
+ const source = await fs.readFile(file, "utf8");
412
+ if (ident.test(source)) count++;
413
+ } catch {
414
+ // skip
415
+ }
416
+ }
417
+ }
418
+ entry.usage_count = count;
419
+ }
420
+ }
421
+
422
+ function escapeRegex(s: string): string {
423
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
424
+ }
425
+
426
+ // ─── MCP definitions + handlers ───────────────────────────────────────
427
+
428
+ export const designToolDefinitions: Tool[] = [
429
+ {
430
+ name: "mandu.design.get",
431
+ description:
432
+ "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.",
433
+ annotations: { readOnlyHint: true },
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ section: {
438
+ type: "string",
439
+ enum: [...DESIGN_SECTION_IDS, "all"],
440
+ description: "Section id to return; `all` dumps the full spec.",
441
+ },
442
+ include_raw: {
443
+ type: "boolean",
444
+ description:
445
+ "Include raw markdown body alongside structured tokens. Default false.",
446
+ },
447
+ },
448
+ required: [],
449
+ },
450
+ },
451
+ {
452
+ name: "mandu.design.prompt",
453
+ description:
454
+ "Return DESIGN.md §9 Agent Prompts — pre-warm payload an agent reads before starting UI work. Empty array + hint when the section is unpopulated.",
455
+ annotations: { readOnlyHint: true },
456
+ inputSchema: {
457
+ type: "object",
458
+ properties: {},
459
+ required: [],
460
+ },
461
+ },
462
+ {
463
+ name: "mandu.design.check",
464
+ description:
465
+ "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`).",
466
+ annotations: { readOnlyHint: true },
467
+ inputSchema: {
468
+ type: "object",
469
+ properties: {
470
+ file: {
471
+ type: "string",
472
+ description:
473
+ "File to check. Relative paths resolve against the project root; absolute paths are accepted as-is.",
474
+ },
475
+ },
476
+ required: ["file"],
477
+ },
478
+ },
479
+ {
480
+ name: "mandu.component.list",
481
+ description:
482
+ "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`.",
483
+ annotations: { readOnlyHint: true },
484
+ inputSchema: {
485
+ type: "object",
486
+ properties: {
487
+ category: {
488
+ type: "string",
489
+ enum: ["ui-primitive", "widget", "all"],
490
+ description: "Filter by category. Defaults to `all`.",
491
+ },
492
+ count_usage: {
493
+ type: "boolean",
494
+ description:
495
+ "When true, count usages across src/** and app/**. Slower; default false.",
496
+ },
497
+ },
498
+ required: [],
499
+ },
500
+ },
501
+ ];
502
+
503
+ export function designTools(projectRoot: string) {
504
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
505
+ "mandu.design.get": async (args) => designGet(projectRoot, args as DesignGetInput),
506
+ "mandu.design.prompt": async () => designPrompt(projectRoot),
507
+ "mandu.design.check": async (args) => designCheck(projectRoot, args as DesignCheckInput),
508
+ "mandu.component.list": async (args) =>
509
+ componentList(projectRoot, args as ComponentListInput),
510
+ };
511
+ return handlers;
512
+ }
@@ -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 },