@mandujs/mcp 0.30.0 → 0.32.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 +3 -3
- package/src/resources/skills/loader.ts +218 -218
- package/src/resources/skills/mandu-deployment/rules/db-provider-supabase.md +300 -0
- package/src/server.ts +2 -1
- package/src/tools/ai-brief.ts +443 -443
- package/src/tools/decisions.ts +270 -270
- package/src/tools/docs.ts +349 -0
- package/src/tools/extract-contract.ts +406 -406
- package/src/tools/guard.ts +56 -3
- package/src/tools/index.ts +8 -0
- package/src/tools/lint.ts +226 -0
- package/src/tools/migrate-route-conventions.ts +345 -345
- package/src/tools/resource.ts +2 -1
- package/src/tools/rewrite-generated-barrel.ts +403 -403
- package/src/resources/skills/mandu-deployment/rules/deploy-platform-supabase.md +0 -323
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools — `mandu.docs.search` + `mandu.docs.get`
|
|
3
|
+
*
|
|
4
|
+
* Issue #243 — agents had 100+ MCP tools but none pointed at the Mandu
|
|
5
|
+
* docs tree. These two tools close that gap by indexing the project's
|
|
6
|
+
* `docs/` directory (where the framework's own markdown lives) and
|
|
7
|
+
* returning search hits + full page bodies.
|
|
8
|
+
*
|
|
9
|
+
* Implementation is deliberately offline-first: we walk `docs/` with
|
|
10
|
+
* `fs.readdir` and grep by title / frontmatter / body. No Pagefind, no
|
|
11
|
+
* network, no extra dependencies — good for any repo that vendors the
|
|
12
|
+
* Mandu docs or has its own markdown tree under `docs/`.
|
|
13
|
+
*
|
|
14
|
+
* Invariants:
|
|
15
|
+
* - Read-only. Never writes files.
|
|
16
|
+
* - Bounded: at most `MAX_FILES` files walked, `MAX_SNIPPET_CHARS` per
|
|
17
|
+
* excerpt. A docs tree of ~10k files would otherwise OOM the handler.
|
|
18
|
+
* - Fails soft: missing `docs/` returns `{ results: [], note: "…" }`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
22
|
+
import path from "path";
|
|
23
|
+
import fs from "fs/promises";
|
|
24
|
+
|
|
25
|
+
const DOCS_DIR_NAME = "docs";
|
|
26
|
+
const MAX_FILES = 5_000;
|
|
27
|
+
const MAX_SNIPPET_CHARS = 280;
|
|
28
|
+
const DEFAULT_LIMIT = 5;
|
|
29
|
+
const MAX_LIMIT = 25;
|
|
30
|
+
|
|
31
|
+
type Scope = "all" | string;
|
|
32
|
+
|
|
33
|
+
interface DocsSearchInput {
|
|
34
|
+
query?: unknown;
|
|
35
|
+
scope?: unknown;
|
|
36
|
+
limit?: unknown;
|
|
37
|
+
includeBody?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DocsGetInput {
|
|
41
|
+
slug?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DocHit {
|
|
45
|
+
slug: string;
|
|
46
|
+
title: string;
|
|
47
|
+
path: string;
|
|
48
|
+
excerpt: string;
|
|
49
|
+
score: number;
|
|
50
|
+
body?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface DocsSearchResult {
|
|
54
|
+
query: string;
|
|
55
|
+
scope: Scope;
|
|
56
|
+
results: DocHit[];
|
|
57
|
+
totalMatched: number;
|
|
58
|
+
note?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface DocsGetResult {
|
|
62
|
+
slug: string;
|
|
63
|
+
path: string;
|
|
64
|
+
title: string;
|
|
65
|
+
body: string;
|
|
66
|
+
note?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Input validation
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function validateSearch(raw: Record<string, unknown>): {
|
|
74
|
+
ok: true; query: string; scope: Scope; limit: number; includeBody: boolean;
|
|
75
|
+
} | { ok: false; error: string; field: string } {
|
|
76
|
+
const q = raw.query;
|
|
77
|
+
if (typeof q !== "string" || q.trim().length === 0) {
|
|
78
|
+
return { ok: false, error: "'query' must be a non-empty string", field: "query" };
|
|
79
|
+
}
|
|
80
|
+
const scope = raw.scope ?? "all";
|
|
81
|
+
if (typeof scope !== "string") {
|
|
82
|
+
return { ok: false, error: "'scope' must be a string or omitted", field: "scope" };
|
|
83
|
+
}
|
|
84
|
+
let limit = DEFAULT_LIMIT;
|
|
85
|
+
if (raw.limit !== undefined) {
|
|
86
|
+
if (typeof raw.limit !== "number" || !Number.isFinite(raw.limit) || raw.limit < 1) {
|
|
87
|
+
return { ok: false, error: "'limit' must be a positive number", field: "limit" };
|
|
88
|
+
}
|
|
89
|
+
limit = Math.min(Math.floor(raw.limit), MAX_LIMIT);
|
|
90
|
+
}
|
|
91
|
+
const includeBody = raw.includeBody === true;
|
|
92
|
+
return { ok: true, query: q.trim(), scope, limit, includeBody };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateGet(raw: Record<string, unknown>): {
|
|
96
|
+
ok: true; slug: string;
|
|
97
|
+
} | { ok: false; error: string; field: string } {
|
|
98
|
+
const s = raw.slug;
|
|
99
|
+
if (typeof s !== "string" || s.trim().length === 0) {
|
|
100
|
+
return { ok: false, error: "'slug' must be a non-empty string", field: "slug" };
|
|
101
|
+
}
|
|
102
|
+
// Block traversal explicitly — the handler joins against `docs/` and
|
|
103
|
+
// would otherwise happily read /etc/passwd on Unix.
|
|
104
|
+
if (s.includes("..") || path.isAbsolute(s)) {
|
|
105
|
+
return { ok: false, error: "'slug' must be a relative docs path without '..'", field: "slug" };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, slug: s.trim() };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Indexing + search
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function walkDocs(rootDir: string, relPrefix = ""): Promise<string[]> {
|
|
115
|
+
const out: string[] = [];
|
|
116
|
+
let entries: import("node:fs").Dirent[];
|
|
117
|
+
try {
|
|
118
|
+
entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (out.length >= MAX_FILES) break;
|
|
124
|
+
const absPath = path.join(rootDir, entry.name);
|
|
125
|
+
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
// Skip generated / hidden noise.
|
|
128
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
129
|
+
const nested = await walkDocs(absPath, relPath);
|
|
130
|
+
out.push(...nested);
|
|
131
|
+
} else if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) {
|
|
132
|
+
out.push(relPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract a human-readable title. Preference order:
|
|
140
|
+
* 1. `title:` frontmatter field
|
|
141
|
+
* 2. First `# heading` line
|
|
142
|
+
* 3. Slug basename
|
|
143
|
+
*/
|
|
144
|
+
function extractTitle(body: string, fallback: string): string {
|
|
145
|
+
// Frontmatter block (--- ... ---) — scan for `title:` only.
|
|
146
|
+
if (body.startsWith("---")) {
|
|
147
|
+
const end = body.indexOf("\n---", 3);
|
|
148
|
+
if (end > 0) {
|
|
149
|
+
const front = body.slice(3, end);
|
|
150
|
+
const m = /\btitle\s*:\s*["']?([^"\n\r]+?)["']?\s*$/m.exec(front);
|
|
151
|
+
if (m?.[1]) return m[1].trim();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const h = /^#\s+(.+)$/m.exec(body);
|
|
155
|
+
if (h?.[1]) return h[1].trim();
|
|
156
|
+
return path.basename(fallback).replace(/\.(md|mdx)$/, "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Cheap, deterministic scoring: +3 for every title hit, +1 per body hit,
|
|
161
|
+
* tie-broken by shorter path (shorter paths are usually more canonical).
|
|
162
|
+
*/
|
|
163
|
+
function scoreDoc(query: string, title: string, body: string, pathLen: number): {
|
|
164
|
+
score: number;
|
|
165
|
+
matchedInTitle: boolean;
|
|
166
|
+
firstHitOffset: number;
|
|
167
|
+
} {
|
|
168
|
+
const q = query.toLowerCase();
|
|
169
|
+
const titleLower = title.toLowerCase();
|
|
170
|
+
const bodyLower = body.toLowerCase();
|
|
171
|
+
let score = 0;
|
|
172
|
+
const matchedInTitle = titleLower.includes(q);
|
|
173
|
+
if (matchedInTitle) score += 3;
|
|
174
|
+
const firstHit = bodyLower.indexOf(q);
|
|
175
|
+
if (firstHit >= 0) score += 1;
|
|
176
|
+
// Every extra hit (bounded at 10 to avoid abuse by repeated-word pages).
|
|
177
|
+
let idx = firstHit;
|
|
178
|
+
let extra = 0;
|
|
179
|
+
while (idx >= 0 && extra < 9) {
|
|
180
|
+
const next = bodyLower.indexOf(q, idx + q.length);
|
|
181
|
+
if (next < 0) break;
|
|
182
|
+
extra += 1;
|
|
183
|
+
idx = next;
|
|
184
|
+
}
|
|
185
|
+
score += extra;
|
|
186
|
+
// Tiebreaker — shorter paths float up. Encoded as a small negative so
|
|
187
|
+
// it never eclipses a body-hit delta.
|
|
188
|
+
score += 1 / (pathLen + 10);
|
|
189
|
+
return { score, matchedInTitle, firstHitOffset: firstHit };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function excerptAround(body: string, hitOffset: number): string {
|
|
193
|
+
if (hitOffset < 0) return body.slice(0, MAX_SNIPPET_CHARS).replace(/\s+/g, " ").trim();
|
|
194
|
+
const start = Math.max(0, hitOffset - Math.floor(MAX_SNIPPET_CHARS / 3));
|
|
195
|
+
const end = Math.min(body.length, start + MAX_SNIPPET_CHARS);
|
|
196
|
+
const slice = body.slice(start, end).replace(/\s+/g, " ").trim();
|
|
197
|
+
return (start > 0 ? "…" : "") + slice + (end < body.length ? "…" : "");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function searchDocs(projectRoot: string, input: {
|
|
201
|
+
query: string; scope: Scope; limit: number; includeBody: boolean;
|
|
202
|
+
}): Promise<DocsSearchResult> {
|
|
203
|
+
const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
|
|
204
|
+
const files = await walkDocs(docsRoot);
|
|
205
|
+
if (files.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
query: input.query,
|
|
208
|
+
scope: input.scope,
|
|
209
|
+
results: [],
|
|
210
|
+
totalMatched: 0,
|
|
211
|
+
note: `No files under ${DOCS_DIR_NAME}/ — project has no local docs tree indexed yet.`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const scopeFilter = input.scope === "all"
|
|
216
|
+
? null
|
|
217
|
+
: new RegExp(`^${input.scope.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|$)`);
|
|
218
|
+
|
|
219
|
+
const hits: DocHit[] = [];
|
|
220
|
+
for (const rel of files) {
|
|
221
|
+
if (scopeFilter && !scopeFilter.test(rel)) continue;
|
|
222
|
+
const abs = path.join(docsRoot, rel);
|
|
223
|
+
let body: string;
|
|
224
|
+
try {
|
|
225
|
+
body = await fs.readFile(abs, "utf-8");
|
|
226
|
+
} catch {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const title = extractTitle(body, rel);
|
|
230
|
+
const { score, firstHitOffset } = scoreDoc(input.query, title, body, rel.length);
|
|
231
|
+
if (score < 1) continue;
|
|
232
|
+
const hit: DocHit = {
|
|
233
|
+
slug: rel,
|
|
234
|
+
title,
|
|
235
|
+
path: path.join(DOCS_DIR_NAME, rel),
|
|
236
|
+
excerpt: excerptAround(body, firstHitOffset),
|
|
237
|
+
score: Math.round(score * 1000) / 1000,
|
|
238
|
+
};
|
|
239
|
+
if (input.includeBody) hit.body = body;
|
|
240
|
+
hits.push(hit);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
hits.sort((a, b) => b.score - a.score);
|
|
244
|
+
const totalMatched = hits.length;
|
|
245
|
+
return {
|
|
246
|
+
query: input.query,
|
|
247
|
+
scope: input.scope,
|
|
248
|
+
results: hits.slice(0, input.limit),
|
|
249
|
+
totalMatched,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function getDoc(projectRoot: string, slug: string): Promise<DocsGetResult> {
|
|
254
|
+
const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
|
|
255
|
+
const abs = path.join(docsRoot, slug);
|
|
256
|
+
// Guard — ensure resolved path still sits inside docsRoot after
|
|
257
|
+
// normalization. Defense in depth vs. the `..` check in validation.
|
|
258
|
+
if (!path.resolve(abs).startsWith(path.resolve(docsRoot) + path.sep)) {
|
|
259
|
+
return {
|
|
260
|
+
slug,
|
|
261
|
+
path: "",
|
|
262
|
+
title: "",
|
|
263
|
+
body: "",
|
|
264
|
+
note: "Resolved path escaped docs/ — refused.",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
let body: string;
|
|
268
|
+
try {
|
|
269
|
+
body = await fs.readFile(abs, "utf-8");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return {
|
|
272
|
+
slug,
|
|
273
|
+
path: path.join(DOCS_DIR_NAME, slug),
|
|
274
|
+
title: path.basename(slug).replace(/\.(md|mdx)$/, ""),
|
|
275
|
+
body: "",
|
|
276
|
+
note: `Read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
slug,
|
|
281
|
+
path: path.join(DOCS_DIR_NAME, slug),
|
|
282
|
+
title: extractTitle(body, slug),
|
|
283
|
+
body,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
288
|
+
// MCP tool definitions + handler map
|
|
289
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export const docsToolDefinitions: Tool[] = [
|
|
292
|
+
{
|
|
293
|
+
name: "mandu.docs.search",
|
|
294
|
+
description:
|
|
295
|
+
"Search the project's docs/ markdown tree by keyword. Returns ranked slugs + excerpts so agents can ground answers in Mandu documentation instead of hallucinating. Read-only. Pass `scope` (e.g. 'architect' or 'bun') to narrow to a subdirectory; `includeBody:true` returns the full MDX body for each hit.",
|
|
296
|
+
annotations: { readOnlyHint: true },
|
|
297
|
+
inputSchema: {
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: {
|
|
300
|
+
query: { type: "string", description: "Search text. Matches title + body (case-insensitive)." },
|
|
301
|
+
scope: {
|
|
302
|
+
type: "string",
|
|
303
|
+
description: "Restrict search to a `docs/` subdirectory (e.g. 'architect', 'bun'). Default 'all'.",
|
|
304
|
+
},
|
|
305
|
+
limit: {
|
|
306
|
+
type: "number",
|
|
307
|
+
description: `Maximum results returned. Default ${DEFAULT_LIMIT}, capped at ${MAX_LIMIT}.`,
|
|
308
|
+
},
|
|
309
|
+
includeBody: {
|
|
310
|
+
type: "boolean",
|
|
311
|
+
description: "Return the full markdown body for each hit. Default false (excerpt only).",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
required: ["query"],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "mandu.docs.get",
|
|
319
|
+
description:
|
|
320
|
+
"Fetch a single markdown page from the project's docs/ tree by relative slug (e.g. 'architect/rendering-modes.md'). Returns the full body + extracted title. Read-only. Pair with `mandu.docs.search` — search to discover, get to read.",
|
|
321
|
+
annotations: { readOnlyHint: true },
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
slug: {
|
|
326
|
+
type: "string",
|
|
327
|
+
description: "Relative path under docs/ (no leading slash, no '..'). Example: 'architect/rendering-modes.md'.",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
required: ["slug"],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
export function docsTools(projectRoot: string) {
|
|
336
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
337
|
+
"mandu.docs.search": async (args) => {
|
|
338
|
+
const v = validateSearch(args as DocsSearchInput as Record<string, unknown>);
|
|
339
|
+
if (!v.ok) return { error: v.error, field: v.field };
|
|
340
|
+
return searchDocs(projectRoot, v);
|
|
341
|
+
},
|
|
342
|
+
"mandu.docs.get": async (args) => {
|
|
343
|
+
const v = validateGet(args as DocsGetInput as Record<string, unknown>);
|
|
344
|
+
if (!v.ok) return { error: v.error, field: v.field };
|
|
345
|
+
return getDoc(projectRoot, v.slug);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
return handlers;
|
|
349
|
+
}
|