@mandujs/mcp 0.19.6 → 0.20.1
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 +4 -3
- package/src/tools/ai-brief.ts +443 -0
- package/src/tools/deploy-preview.ts +316 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/loop-close.ts +175 -0
- package/src/tools/run-tests.ts +424 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@mandujs/core": "^0.
|
|
38
|
-
"@mandujs/ate": "^0.
|
|
37
|
+
"@mandujs/core": "^0.24.0",
|
|
38
|
+
"@mandujs/ate": "^0.19.1",
|
|
39
|
+
"@mandujs/skills": "^4.0.0",
|
|
39
40
|
"@modelcontextprotocol/sdk": "^1.25.3"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool — `mandu.ai.brief`
|
|
3
|
+
*
|
|
4
|
+
* Assembles a structured briefing for an AI agent joining a project:
|
|
5
|
+
* - project title + summary (from `package.json` / `mandu.config`)
|
|
6
|
+
* - skills manifest (static `@mandujs/skills` list + any generated
|
|
7
|
+
* per-project skills under `.claude/skills/`)
|
|
8
|
+
* - recent changes (last 20 git commits — subject + hash + author)
|
|
9
|
+
* - relevant docs index (top-level `docs/` headings)
|
|
10
|
+
* - suggested next-steps derived from existing recent-activity signals
|
|
11
|
+
*
|
|
12
|
+
* Invariants:
|
|
13
|
+
* - Read-only. Never writes files, never spawns long-running processes.
|
|
14
|
+
* - Returns a structured JSON shape — the MCP client renders as needed.
|
|
15
|
+
* - Fails soft: missing `docs/` or `git` history produces empty fields,
|
|
16
|
+
* not an error.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
import { spawn } from "bun";
|
|
21
|
+
import path from "path";
|
|
22
|
+
import fs from "fs/promises";
|
|
23
|
+
import { readConfig } from "../utils/project.js";
|
|
24
|
+
|
|
25
|
+
type Depth = "short" | "full";
|
|
26
|
+
|
|
27
|
+
interface AiBriefInput {
|
|
28
|
+
depth?: Depth;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SkillEntry {
|
|
32
|
+
id: string;
|
|
33
|
+
source: "static" | "generated";
|
|
34
|
+
path?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CommitEntry {
|
|
38
|
+
hash: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
author?: string;
|
|
41
|
+
date?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DocEntry {
|
|
45
|
+
path: string;
|
|
46
|
+
title: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AiBriefResult {
|
|
50
|
+
title: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
depth: Depth;
|
|
53
|
+
files: string[];
|
|
54
|
+
skills: SkillEntry[];
|
|
55
|
+
recent_changes: CommitEntry[];
|
|
56
|
+
docs: DocEntry[];
|
|
57
|
+
config: {
|
|
58
|
+
guard_preset?: string;
|
|
59
|
+
fs_routes?: boolean;
|
|
60
|
+
has_playwright?: boolean;
|
|
61
|
+
};
|
|
62
|
+
suggested_next: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Validation
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function validateInput(raw: Record<string, unknown>): {
|
|
70
|
+
ok: true;
|
|
71
|
+
depth: Depth;
|
|
72
|
+
} | { ok: false; error: string; field: string; hint: string } {
|
|
73
|
+
const depth = raw.depth ?? "short";
|
|
74
|
+
if (typeof depth !== "string" || (depth !== "short" && depth !== "full")) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: "'depth' must be 'short' or 'full'",
|
|
78
|
+
field: "depth",
|
|
79
|
+
hint: "Omit to default to 'short'",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, depth: depth as Depth };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// Data collectors
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
90
|
+
try {
|
|
91
|
+
await fs.access(p);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readPackageInfo(projectRoot: string): Promise<{
|
|
99
|
+
name: string;
|
|
100
|
+
description: string;
|
|
101
|
+
version?: string;
|
|
102
|
+
}> {
|
|
103
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
104
|
+
try {
|
|
105
|
+
const raw = await fs.readFile(pkgPath, "utf8");
|
|
106
|
+
const pkg = JSON.parse(raw) as {
|
|
107
|
+
name?: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
version?: string;
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
name: pkg.name ?? path.basename(projectRoot),
|
|
113
|
+
description: pkg.description ?? "",
|
|
114
|
+
...(pkg.version ? { version: pkg.version } : {}),
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
return { name: path.basename(projectRoot), description: "" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Discover the static skill catalog. Prefer `@mandujs/skills/SKILL_IDS`
|
|
123
|
+
* when reachable, else fall back to a hard-coded canonical list. We
|
|
124
|
+
* deliberately avoid importing the skills package at module-eval time
|
|
125
|
+
* to keep the MCP server startup fast — the list is tiny.
|
|
126
|
+
*/
|
|
127
|
+
const STATIC_SKILL_IDS: readonly string[] = [
|
|
128
|
+
"mandu-create-feature",
|
|
129
|
+
"mandu-create-api",
|
|
130
|
+
"mandu-debug",
|
|
131
|
+
"mandu-explain",
|
|
132
|
+
"mandu-guard-guide",
|
|
133
|
+
"mandu-deploy",
|
|
134
|
+
"mandu-slot",
|
|
135
|
+
"mandu-fs-routes",
|
|
136
|
+
"mandu-hydration",
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
async function collectSkills(projectRoot: string): Promise<SkillEntry[]> {
|
|
140
|
+
const out: SkillEntry[] = STATIC_SKILL_IDS.map((id) => ({
|
|
141
|
+
id,
|
|
142
|
+
source: "static" as const,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const skillsDir = path.join(projectRoot, ".claude", "skills");
|
|
146
|
+
if (!(await fileExists(skillsDir))) return out;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const entries = await fs.readdir(skillsDir);
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (!entry.endsWith(".md")) continue;
|
|
152
|
+
out.push({
|
|
153
|
+
id: entry.replace(/\.md$/, ""),
|
|
154
|
+
source: "generated",
|
|
155
|
+
path: path.join(skillsDir, entry),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// ignore
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function collectRecentCommits(
|
|
165
|
+
projectRoot: string,
|
|
166
|
+
limit: number,
|
|
167
|
+
): Promise<CommitEntry[]> {
|
|
168
|
+
const proc = spawn(
|
|
169
|
+
[
|
|
170
|
+
"git",
|
|
171
|
+
"log",
|
|
172
|
+
`-${limit}`,
|
|
173
|
+
"--pretty=format:%H%x09%s%x09%an%x09%ad",
|
|
174
|
+
"--date=short",
|
|
175
|
+
],
|
|
176
|
+
{
|
|
177
|
+
cwd: projectRoot,
|
|
178
|
+
stdout: "pipe",
|
|
179
|
+
stderr: "pipe",
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
let timedOut = false;
|
|
184
|
+
const handle = setTimeout(() => {
|
|
185
|
+
timedOut = true;
|
|
186
|
+
try {
|
|
187
|
+
proc.kill();
|
|
188
|
+
} catch {}
|
|
189
|
+
}, 10_000);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const [stdout, exit] = await Promise.all([
|
|
193
|
+
new Response(proc.stdout).text(),
|
|
194
|
+
proc.exited,
|
|
195
|
+
]);
|
|
196
|
+
if (timedOut || exit !== 0 || !stdout) return [];
|
|
197
|
+
|
|
198
|
+
const commits: CommitEntry[] = [];
|
|
199
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
200
|
+
if (!line.trim()) continue;
|
|
201
|
+
const [hash, subject, author, date] = line.split("\t");
|
|
202
|
+
if (!hash || !subject) continue;
|
|
203
|
+
commits.push({
|
|
204
|
+
hash: hash.slice(0, 12),
|
|
205
|
+
subject,
|
|
206
|
+
...(author ? { author } : {}),
|
|
207
|
+
...(date ? { date } : {}),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return commits;
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
} finally {
|
|
214
|
+
clearTimeout(handle);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function collectDocs(projectRoot: string, limit: number): Promise<DocEntry[]> {
|
|
219
|
+
const docsDir = path.join(projectRoot, "docs");
|
|
220
|
+
if (!(await fileExists(docsDir))) return [];
|
|
221
|
+
|
|
222
|
+
const out: DocEntry[] = [];
|
|
223
|
+
try {
|
|
224
|
+
const entries = await fs.readdir(docsDir, { withFileTypes: true });
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
227
|
+
const p = path.join(docsDir, entry.name);
|
|
228
|
+
const title = await extractMarkdownTitle(p, entry.name);
|
|
229
|
+
out.push({ path: p, title });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Sort alphabetically for determinism, cap to `limit`.
|
|
237
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
238
|
+
return out.slice(0, limit);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function extractMarkdownTitle(
|
|
242
|
+
filePath: string,
|
|
243
|
+
fallback: string,
|
|
244
|
+
): Promise<string> {
|
|
245
|
+
try {
|
|
246
|
+
// Read only the first ~4KB — title is always near the top.
|
|
247
|
+
const file = Bun.file(filePath);
|
|
248
|
+
const head = await file.slice(0, 4096).text();
|
|
249
|
+
const match = /^#\s+(.+)$/m.exec(head);
|
|
250
|
+
if (match) return match[1].trim();
|
|
251
|
+
} catch {
|
|
252
|
+
// ignore
|
|
253
|
+
}
|
|
254
|
+
return fallback;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function collectConfigSummary(projectRoot: string): Promise<{
|
|
258
|
+
guard_preset?: string;
|
|
259
|
+
fs_routes?: boolean;
|
|
260
|
+
has_playwright?: boolean;
|
|
261
|
+
}> {
|
|
262
|
+
const summary: {
|
|
263
|
+
guard_preset?: string;
|
|
264
|
+
fs_routes?: boolean;
|
|
265
|
+
has_playwright?: boolean;
|
|
266
|
+
} = {};
|
|
267
|
+
try {
|
|
268
|
+
const cfg = await readConfig(projectRoot);
|
|
269
|
+
if (cfg && typeof cfg === "object") {
|
|
270
|
+
const guard = (cfg as { guard?: { preset?: unknown } }).guard;
|
|
271
|
+
if (guard && typeof guard === "object") {
|
|
272
|
+
const preset = (guard as { preset?: unknown }).preset;
|
|
273
|
+
if (typeof preset === "string") summary.guard_preset = preset;
|
|
274
|
+
}
|
|
275
|
+
const fsRoutes = (cfg as { fsRoutes?: unknown }).fsRoutes;
|
|
276
|
+
if (typeof fsRoutes === "boolean") summary.fs_routes = fsRoutes;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// ignore
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
284
|
+
const raw = await fs.readFile(pkgPath, "utf8");
|
|
285
|
+
const pkg = JSON.parse(raw) as {
|
|
286
|
+
devDependencies?: Record<string, string>;
|
|
287
|
+
dependencies?: Record<string, string>;
|
|
288
|
+
};
|
|
289
|
+
const allDeps = { ...(pkg.devDependencies ?? {}), ...(pkg.dependencies ?? {}) };
|
|
290
|
+
if ("@playwright/test" in allDeps || "playwright" in allDeps) {
|
|
291
|
+
summary.has_playwright = true;
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// ignore
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return summary;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildSuggestedNext(args: {
|
|
301
|
+
commits: CommitEntry[];
|
|
302
|
+
guardPreset?: string;
|
|
303
|
+
hasGeneratedSkills: boolean;
|
|
304
|
+
}): string[] {
|
|
305
|
+
const out: string[] = [];
|
|
306
|
+
|
|
307
|
+
// If there are no generated skills, suggest creating them.
|
|
308
|
+
if (!args.hasGeneratedSkills) {
|
|
309
|
+
out.push(
|
|
310
|
+
"Run `mandu skills:generate` to emit project-specific `.claude/skills/` files (domain glossary, conventions, workflow).",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// If the most recent commit mentions "WIP", suggest continuing that work.
|
|
315
|
+
const wip = args.commits.find((c) => /\bWIP\b/i.test(c.subject));
|
|
316
|
+
if (wip) {
|
|
317
|
+
out.push(
|
|
318
|
+
`Continue the WIP work referenced by \`${wip.hash}\` — "${wip.subject}".`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Always suggest running tests as a safe baseline.
|
|
323
|
+
out.push(
|
|
324
|
+
"Run `mandu_run_tests` with `{target:'all'}` to establish a green baseline before making changes.",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// If a guard preset is configured, surface it.
|
|
328
|
+
if (args.guardPreset) {
|
|
329
|
+
out.push(
|
|
330
|
+
`Respect the \`${args.guardPreset}\` architecture preset when proposing changes — run \`mandu.guard.check\` to confirm compliance.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
338
|
+
// Handler
|
|
339
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async function buildAiBrief(
|
|
342
|
+
projectRoot: string,
|
|
343
|
+
input: AiBriefInput,
|
|
344
|
+
): Promise<AiBriefResult | { error: string; field?: string; hint?: string }> {
|
|
345
|
+
const validated = validateInput(input as Record<string, unknown>);
|
|
346
|
+
if (!validated.ok) {
|
|
347
|
+
return {
|
|
348
|
+
error: validated.error,
|
|
349
|
+
field: validated.field,
|
|
350
|
+
hint: validated.hint,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const depth = validated.depth;
|
|
355
|
+
const pkg = await readPackageInfo(projectRoot);
|
|
356
|
+
const skills = await collectSkills(projectRoot);
|
|
357
|
+
const commits = await collectRecentCommits(projectRoot, 20);
|
|
358
|
+
const docsLimit = depth === "full" ? 40 : 10;
|
|
359
|
+
const docs = await collectDocs(projectRoot, docsLimit);
|
|
360
|
+
const config = await collectConfigSummary(projectRoot);
|
|
361
|
+
|
|
362
|
+
const files: string[] = [];
|
|
363
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
364
|
+
if (await fileExists(pkgPath)) files.push(pkgPath);
|
|
365
|
+
for (const cfgName of ["mandu.config.ts", "mandu.config.js", "mandu.config.json"]) {
|
|
366
|
+
const p = path.join(projectRoot, cfgName);
|
|
367
|
+
if (await fileExists(p)) files.push(p);
|
|
368
|
+
}
|
|
369
|
+
const manifestPath = path.join(projectRoot, ".mandu", "routes.manifest.json");
|
|
370
|
+
if (await fileExists(manifestPath)) files.push(manifestPath);
|
|
371
|
+
const agentsMd = path.join(projectRoot, "AGENTS.md");
|
|
372
|
+
if (await fileExists(agentsMd)) files.push(agentsMd);
|
|
373
|
+
const claudeMd = path.join(projectRoot, "CLAUDE.md");
|
|
374
|
+
if (await fileExists(claudeMd)) files.push(claudeMd);
|
|
375
|
+
|
|
376
|
+
const title = pkg.name + (pkg.version ? ` @ ${pkg.version}` : "");
|
|
377
|
+
const summary = pkg.description ||
|
|
378
|
+
"A Mandu project — Bun-native TypeScript full-stack framework.";
|
|
379
|
+
|
|
380
|
+
const hasGeneratedSkills = skills.some((s) => s.source === "generated");
|
|
381
|
+
const suggested_next = buildSuggestedNext({
|
|
382
|
+
commits,
|
|
383
|
+
guardPreset: config.guard_preset,
|
|
384
|
+
hasGeneratedSkills,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const result: AiBriefResult = {
|
|
388
|
+
title,
|
|
389
|
+
summary,
|
|
390
|
+
depth,
|
|
391
|
+
files,
|
|
392
|
+
skills,
|
|
393
|
+
recent_changes: commits,
|
|
394
|
+
docs,
|
|
395
|
+
config,
|
|
396
|
+
suggested_next,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// In "short" depth, trim the most verbose collections.
|
|
400
|
+
if (depth === "short") {
|
|
401
|
+
result.skills = result.skills.slice(0, 12);
|
|
402
|
+
result.recent_changes = result.recent_changes.slice(0, 5);
|
|
403
|
+
result.docs = result.docs.slice(0, 5);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
410
|
+
// MCP tool definition + handler map
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
export const aiBriefToolDefinitions: Tool[] = [
|
|
414
|
+
{
|
|
415
|
+
name: "mandu.ai.brief",
|
|
416
|
+
description:
|
|
417
|
+
"Assemble an AI agent briefing: project title, description, skills manifest, last 20 git commits, docs/ index, and config snapshot. Pass `depth:'full'` for the unabridged view; default `short` keeps the payload small. Read-only.",
|
|
418
|
+
annotations: {
|
|
419
|
+
readOnlyHint: true,
|
|
420
|
+
},
|
|
421
|
+
inputSchema: {
|
|
422
|
+
type: "object",
|
|
423
|
+
properties: {
|
|
424
|
+
depth: {
|
|
425
|
+
type: "string",
|
|
426
|
+
enum: ["short", "full"],
|
|
427
|
+
description: "Brief depth — `short` (default) trims lists for fast ingestion; `full` returns the complete view.",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
required: [],
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
export function aiBriefTools(projectRoot: string) {
|
|
436
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
437
|
+
"mandu.ai.brief": async (args) => buildAiBrief(projectRoot, args as AiBriefInput),
|
|
438
|
+
};
|
|
439
|
+
return handlers;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Exported for unit tests
|
|
443
|
+
export { buildSuggestedNext };
|