@mandujs/mcp 0.19.5 → 0.20.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,44 +1,45 @@
1
- {
2
- "name": "@mandujs/mcp",
3
- "version": "0.19.5",
4
- "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "bin": {
8
- "mandu-mcp": "./src/index.ts",
9
- "mandujs-mcp": "./src/index.ts",
10
- "mcp": "./src/index.ts"
11
- },
12
- "exports": {
13
- ".": "./src/index.ts"
14
- },
15
- "files": [
16
- "src/**/*"
17
- ],
18
- "keywords": [
19
- "mandu",
20
- "mcp",
21
- "model-context-protocol",
22
- "ai",
23
- "agent",
24
- "code-generation"
25
- ],
26
- "repository": {
27
- "type": "git",
28
- "url": "https://github.com/konamgil/mandu.git",
29
- "directory": "packages/mcp"
30
- },
31
- "author": "konamgil",
32
- "license": "MPL-2.0",
33
- "publishConfig": {
34
- "access": "public"
35
- },
36
- "dependencies": {
37
- "@mandujs/core": "^0.21.0",
38
- "@mandujs/ate": "^0.18.0",
39
- "@modelcontextprotocol/sdk": "^1.25.3"
40
- },
41
- "engines": {
42
- "bun": ">=1.0.0"
43
- }
44
- }
1
+ {
2
+ "name": "@mandujs/mcp",
3
+ "version": "0.20.0",
4
+ "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "bin": {
8
+ "mandu-mcp": "./src/index.ts",
9
+ "mandujs-mcp": "./src/index.ts",
10
+ "mcp": "./src/index.ts"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "files": [
16
+ "src/**/*"
17
+ ],
18
+ "keywords": [
19
+ "mandu",
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "ai",
23
+ "agent",
24
+ "code-generation"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/konamgil/mandu.git",
29
+ "directory": "packages/mcp"
30
+ },
31
+ "author": "konamgil",
32
+ "license": "MPL-2.0",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@mandujs/core": "^0.23.0",
38
+ "@mandujs/ate": "^0.19.0",
39
+ "@mandujs/skills": "^3.0.0",
40
+ "@modelcontextprotocol/sdk": "^1.25.3"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.3.12"
44
+ }
45
+ }
package/src/index.ts CHANGED
File without changes
@@ -9,6 +9,7 @@ import path from "path";
9
9
  import { readConfig, readJsonFile } from "./utils/project.js";
10
10
  import { loadManduConfig, loadManifest } from "@mandujs/core";
11
11
  import { eventBus } from "@mandujs/core/observability";
12
+ import { computeAgentStats } from "@mandujs/core/kitchen";
12
13
  import { getDevServerState } from "./tools/project.js";
13
14
 
14
15
  export const manduResourceDefinitions: Resource[] = [
@@ -36,6 +37,12 @@ export const manduResourceDefinitions: Resource[] = [
36
37
  description: "Recent observability events (HTTP, MCP, Guard) from EventBus + 5-minute stats",
37
38
  mimeType: "application/json",
38
39
  },
40
+ {
41
+ uri: "mandu://agent-stats",
42
+ name: "Per-Agent Stats",
43
+ description: "MCP tool usage aggregated by sessionId (per-agent observability)",
44
+ mimeType: "application/json",
45
+ },
39
46
  ];
40
47
 
41
48
  type ResourceReadResult = { uri: string; mimeType: string; text: string };
@@ -107,6 +114,11 @@ export function manduResourceHandlers(projectRoot: string): Record<string, Resou
107
114
  });
108
115
  },
109
116
 
117
+ "mandu://agent-stats": async () => {
118
+ // Phase 5-2: per-agent (sessionId) MCP tool usage aggregation
119
+ return jsonResult("mandu://agent-stats", computeAgentStats());
120
+ },
121
+
110
122
  "mandu://errors": async () => {
111
123
  const errors: unknown[] = [];
112
124
 
@@ -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 };