@mandujs/mcp 0.38.12 → 0.39.0-beta.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.
Files changed (183) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -5
  3. package/src/activity-adapter.ts +23 -23
  4. package/src/activity-monitor.ts +39 -10
  5. package/src/adapters/index.ts +20 -20
  6. package/src/adapters/monitor-adapter.ts +100 -100
  7. package/src/adapters/tool-adapter.ts +90 -90
  8. package/src/executor/index.ts +22 -22
  9. package/src/executor/tool-executor.ts +148 -148
  10. package/src/hooks/config-watcher.ts +173 -173
  11. package/src/hooks/index.ts +23 -23
  12. package/src/hooks/mcp-hooks.ts +227 -227
  13. package/src/index.ts +5 -5
  14. package/src/logging/index.ts +15 -15
  15. package/src/logging/mcp-transport.ts +134 -134
  16. package/src/new-resources.ts +2 -2
  17. package/src/profiles.ts +25 -54
  18. package/src/prompts.ts +4 -4
  19. package/src/registry/index.ts +13 -13
  20. package/src/registry/mcp-tool-registry.ts +298 -298
  21. package/src/resources/generated-skills/catalog.ts +36 -0
  22. package/src/resources/generated-skills/mandu-agent-workflow/SKILL.md +48 -0
  23. package/src/resources/generated-skills/mandu-contract/SKILL.md +20 -0
  24. package/src/resources/generated-skills/mandu-fs-routes/SKILL.md +19 -0
  25. package/src/resources/generated-skills/mandu-guard/SKILL.md +20 -0
  26. package/src/resources/generated-skills/mandu-hydration/SKILL.md +19 -0
  27. package/src/resources/generated-skills/mandu-testing/SKILL.md +20 -0
  28. package/src/resources/handlers.ts +3 -3
  29. package/src/resources/skills/guides.ts +49 -49
  30. package/src/resources/skills/index.ts +12 -12
  31. package/src/resources/skills/loader.ts +8 -35
  32. package/src/resources/skills/recipes.ts +28 -28
  33. package/src/server.ts +1 -1
  34. package/src/tools/agent.ts +479 -409
  35. package/src/tools/ate-exemplar.ts +92 -92
  36. package/src/tools/ate-flakes.ts +90 -90
  37. package/src/tools/ate-mutate.ts +103 -103
  38. package/src/tools/ate-mutation-report.ts +64 -64
  39. package/src/tools/ate-oracle-pending.ts +49 -49
  40. package/src/tools/ate-oracle-replay.ts +44 -44
  41. package/src/tools/ate-oracle-verdict.ts +70 -70
  42. package/src/tools/ate-prompt.ts +146 -146
  43. package/src/tools/ate-run.ts +1 -1
  44. package/src/tools/ate.ts +38 -38
  45. package/src/tools/brain.ts +6 -6
  46. package/src/tools/composite.ts +19 -61
  47. package/src/tools/contract.ts +11 -9
  48. package/src/tools/deploy-plan.ts +2 -2
  49. package/src/tools/deploy-preview.ts +316 -316
  50. package/src/tools/design.ts +825 -825
  51. package/src/tools/docs.ts +350 -350
  52. package/src/tools/generate.ts +4 -3
  53. package/src/tools/guard.ts +1 -1
  54. package/src/tools/history.ts +1 -1
  55. package/src/tools/hydration.ts +64 -64
  56. package/src/tools/index.ts +0 -118
  57. package/src/tools/kitchen.ts +72 -72
  58. package/src/tools/lint.ts +226 -226
  59. package/src/tools/loop-close.ts +175 -175
  60. package/src/tools/negotiate.ts +263 -263
  61. package/src/tools/resource.ts +1 -1
  62. package/src/tools/run-tests.ts +424 -424
  63. package/src/tools/runtime.ts +1 -1
  64. package/src/tools/seo.ts +1 -1
  65. package/src/tools/slot-validation.ts +19 -19
  66. package/src/tools/spec.ts +201 -201
  67. package/src/tools/transaction.ts +1 -1
  68. package/src/tx-lock.ts +73 -73
  69. package/src/utils/runtime-control.ts +52 -52
  70. package/src/utils/withWarnings.ts +1 -1
  71. package/src/resources/skills/mandu-agent-workflow/SKILL.md +0 -124
  72. package/src/resources/skills/mandu-agent-workflow/metadata.json +0 -7
  73. package/src/resources/skills/mandu-composition/SKILL.md +0 -131
  74. package/src/resources/skills/mandu-composition/metadata.json +0 -13
  75. package/src/resources/skills/mandu-composition/rules/_sections.md +0 -26
  76. package/src/resources/skills/mandu-composition/rules/_template.md +0 -77
  77. package/src/resources/skills/mandu-composition/rules/comp-arch-avoid-boolean-props.md +0 -146
  78. package/src/resources/skills/mandu-composition/rules/comp-arch-compound-components.md +0 -164
  79. package/src/resources/skills/mandu-composition/rules/comp-island-event.md +0 -161
  80. package/src/resources/skills/mandu-composition/rules/comp-island-slot-split.md +0 -167
  81. package/src/resources/skills/mandu-composition/rules/comp-pattern-children.md +0 -149
  82. package/src/resources/skills/mandu-composition/rules/comp-state-context-interface.md +0 -148
  83. package/src/resources/skills/mandu-composition/rules/comp-state-lift-state.md +0 -150
  84. package/src/resources/skills/mandu-deployment/SKILL.md +0 -135
  85. package/src/resources/skills/mandu-deployment/_sections.md +0 -41
  86. package/src/resources/skills/mandu-deployment/_template.md +0 -38
  87. package/src/resources/skills/mandu-deployment/metadata.json +0 -13
  88. package/src/resources/skills/mandu-deployment/rules/db-provider-supabase.md +0 -300
  89. package/src/resources/skills/mandu-deployment/rules/deploy-build-bun.md +0 -109
  90. package/src/resources/skills/mandu-deployment/rules/deploy-build-output.md +0 -115
  91. package/src/resources/skills/mandu-deployment/rules/deploy-cicd-github.md +0 -219
  92. package/src/resources/skills/mandu-deployment/rules/deploy-docker-bun.md +0 -150
  93. package/src/resources/skills/mandu-deployment/rules/deploy-docker-compose.md +0 -223
  94. package/src/resources/skills/mandu-deployment/rules/deploy-platform-fly.md +0 -152
  95. package/src/resources/skills/mandu-deployment/rules/deploy-platform-render.md +0 -179
  96. package/src/resources/skills/mandu-deployment/rules/deploy-platform-vercel.md +0 -140
  97. package/src/resources/skills/mandu-fs-routes/SKILL.md +0 -122
  98. package/src/resources/skills/mandu-fs-routes/metadata.json +0 -12
  99. package/src/resources/skills/mandu-fs-routes/rules/_sections.md +0 -36
  100. package/src/resources/skills/mandu-fs-routes/rules/_template.md +0 -69
  101. package/src/resources/skills/mandu-fs-routes/rules/routes-api-methods.md +0 -65
  102. package/src/resources/skills/mandu-fs-routes/rules/routes-dynamic-param.md +0 -93
  103. package/src/resources/skills/mandu-fs-routes/rules/routes-naming-page.md +0 -55
  104. package/src/resources/skills/mandu-guard/SKILL.md +0 -162
  105. package/src/resources/skills/mandu-guard/metadata.json +0 -12
  106. package/src/resources/skills/mandu-guard/rules/_sections.md +0 -36
  107. package/src/resources/skills/mandu-guard/rules/_template.md +0 -82
  108. package/src/resources/skills/mandu-guard/rules/guard-config-rules.md +0 -100
  109. package/src/resources/skills/mandu-guard/rules/guard-layer-direction.md +0 -76
  110. package/src/resources/skills/mandu-guard/rules/guard-preset-mandu.md +0 -81
  111. package/src/resources/skills/mandu-guard/rules/guard-validate-import.md +0 -80
  112. package/src/resources/skills/mandu-hydration/SKILL.md +0 -139
  113. package/src/resources/skills/mandu-hydration/metadata.json +0 -12
  114. package/src/resources/skills/mandu-hydration/rules/_sections.md +0 -31
  115. package/src/resources/skills/mandu-hydration/rules/_template.md +0 -72
  116. package/src/resources/skills/mandu-hydration/rules/hydration-data-event.md +0 -109
  117. package/src/resources/skills/mandu-hydration/rules/hydration-directive-use-client.md +0 -55
  118. package/src/resources/skills/mandu-hydration/rules/hydration-island-setup.md +0 -160
  119. package/src/resources/skills/mandu-hydration/rules/hydration-priority-visible.md +0 -91
  120. package/src/resources/skills/mandu-performance/SKILL.md +0 -125
  121. package/src/resources/skills/mandu-performance/metadata.json +0 -14
  122. package/src/resources/skills/mandu-performance/rules/_sections.md +0 -31
  123. package/src/resources/skills/mandu-performance/rules/_template.md +0 -64
  124. package/src/resources/skills/mandu-performance/rules/perf-async-defer-await.md +0 -103
  125. package/src/resources/skills/mandu-performance/rules/perf-async-parallel.md +0 -95
  126. package/src/resources/skills/mandu-performance/rules/perf-bun-file.md +0 -124
  127. package/src/resources/skills/mandu-performance/rules/perf-bun-serve.md +0 -125
  128. package/src/resources/skills/mandu-performance/rules/perf-bundle-imports.md +0 -80
  129. package/src/resources/skills/mandu-performance/rules/perf-bundle-island-lazy.md +0 -145
  130. package/src/resources/skills/mandu-performance/rules/perf-cache-react.md +0 -98
  131. package/src/resources/skills/mandu-performance/rules/perf-render-transitions.md +0 -154
  132. package/src/resources/skills/mandu-security/SKILL.md +0 -127
  133. package/src/resources/skills/mandu-security/metadata.json +0 -13
  134. package/src/resources/skills/mandu-security/rules/_sections.md +0 -31
  135. package/src/resources/skills/mandu-security/rules/_template.md +0 -74
  136. package/src/resources/skills/mandu-security/rules/sec-auth-guard.md +0 -127
  137. package/src/resources/skills/mandu-security/rules/sec-env-management.md +0 -133
  138. package/src/resources/skills/mandu-security/rules/sec-input-validate.md +0 -148
  139. package/src/resources/skills/mandu-security/rules/sec-protect-csrf.md +0 -146
  140. package/src/resources/skills/mandu-security/rules/sec-protect-headers.md +0 -138
  141. package/src/resources/skills/mandu-slot/SKILL.md +0 -125
  142. package/src/resources/skills/mandu-slot/metadata.json +0 -12
  143. package/src/resources/skills/mandu-slot/rules/_sections.md +0 -36
  144. package/src/resources/skills/mandu-slot/rules/_template.md +0 -63
  145. package/src/resources/skills/mandu-slot/rules/slot-basic-structure.md +0 -38
  146. package/src/resources/skills/mandu-slot/rules/slot-ctx-response.md +0 -56
  147. package/src/resources/skills/mandu-slot/rules/slot-guard-auth.md +0 -59
  148. package/src/resources/skills/mandu-slot/rules/slot-http-methods.md +0 -64
  149. package/src/resources/skills/mandu-styling/SKILL.md +0 -196
  150. package/src/resources/skills/mandu-styling/_sections.md +0 -43
  151. package/src/resources/skills/mandu-styling/_template.md +0 -32
  152. package/src/resources/skills/mandu-styling/metadata.json +0 -15
  153. package/src/resources/skills/mandu-styling/rules/style-component-compound.md +0 -235
  154. package/src/resources/skills/mandu-styling/rules/style-component-slots.md +0 -255
  155. package/src/resources/skills/mandu-styling/rules/style-component-tokens.md +0 -205
  156. package/src/resources/skills/mandu-styling/rules/style-island-animations.md +0 -272
  157. package/src/resources/skills/mandu-styling/rules/style-island-scoping.md +0 -167
  158. package/src/resources/skills/mandu-styling/rules/style-island-variants.md +0 -221
  159. package/src/resources/skills/mandu-styling/rules/style-perf-critical.md +0 -209
  160. package/src/resources/skills/mandu-styling/rules/style-perf-purge.md +0 -192
  161. package/src/resources/skills/mandu-styling/rules/style-setup-modules.md +0 -162
  162. package/src/resources/skills/mandu-styling/rules/style-setup-panda.md +0 -164
  163. package/src/resources/skills/mandu-styling/rules/style-setup-tailwind.md +0 -170
  164. package/src/resources/skills/mandu-styling/rules/style-tailwind-v4-gotchas.md +0 -179
  165. package/src/resources/skills/mandu-styling/rules/style-theme-darkmode.md +0 -229
  166. package/src/resources/skills/mandu-testing/SKILL.md +0 -132
  167. package/src/resources/skills/mandu-testing/metadata.json +0 -13
  168. package/src/resources/skills/mandu-testing/rules/_sections.md +0 -26
  169. package/src/resources/skills/mandu-testing/rules/_template.md +0 -65
  170. package/src/resources/skills/mandu-testing/rules/test-component-island.md +0 -195
  171. package/src/resources/skills/mandu-testing/rules/test-e2e-playwright.md +0 -196
  172. package/src/resources/skills/mandu-testing/rules/test-mock-fetch.md +0 -219
  173. package/src/resources/skills/mandu-testing/rules/test-slot-unit.md +0 -192
  174. package/src/resources/skills/mandu-ui/SKILL.md +0 -159
  175. package/src/resources/skills/mandu-ui/_sections.md +0 -23
  176. package/src/resources/skills/mandu-ui/_template.md +0 -32
  177. package/src/resources/skills/mandu-ui/metadata.json +0 -13
  178. package/src/resources/skills/mandu-ui/rules/ui-accessibility-aria.md +0 -232
  179. package/src/resources/skills/mandu-ui/rules/ui-accessibility-focus.md +0 -238
  180. package/src/resources/skills/mandu-ui/rules/ui-composition-patterns.md +0 -259
  181. package/src/resources/skills/mandu-ui/rules/ui-island-integration.md +0 -258
  182. package/src/resources/skills/mandu-ui/rules/ui-radix-patterns.md +0 -213
  183. package/src/resources/skills/mandu-ui/rules/ui-shadcn-setup.md +0 -209
package/src/tools/docs.ts CHANGED
@@ -1,350 +1,350 @@
1
- import type * as __ManduNodeFsTypes0 from "node:fs";
2
- /**
3
- * MCP tools — `mandu.docs.search` + `mandu.docs.get`
4
- *
5
- * Issue #243 — agents had 100+ MCP tools but none pointed at the Mandu
6
- * docs tree. These two tools close that gap by indexing the project's
7
- * `docs/` directory (where the framework's own markdown lives) and
8
- * returning search hits + full page bodies.
9
- *
10
- * Implementation is deliberately offline-first: we walk `docs/` with
11
- * `fs.readdir` and grep by title / frontmatter / body. No Pagefind, no
12
- * network, no extra dependencies — good for any repo that vendors the
13
- * Mandu docs or has its own markdown tree under `docs/`.
14
- *
15
- * Invariants:
16
- * - Read-only. Never writes files.
17
- * - Bounded: at most `MAX_FILES` files walked, `MAX_SNIPPET_CHARS` per
18
- * excerpt. A docs tree of ~10k files would otherwise OOM the handler.
19
- * - Fails soft: missing `docs/` returns `{ results: [], note: "…" }`.
20
- */
21
-
22
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
23
- import path from "path";
24
- import fs from "fs/promises";
25
-
26
- const DOCS_DIR_NAME = "docs";
27
- const MAX_FILES = 5_000;
28
- const MAX_SNIPPET_CHARS = 280;
29
- const DEFAULT_LIMIT = 5;
30
- const MAX_LIMIT = 25;
31
-
32
- type Scope = "all" | string;
33
-
34
- interface DocsSearchInput {
35
- query?: unknown;
36
- scope?: unknown;
37
- limit?: unknown;
38
- includeBody?: unknown;
39
- }
40
-
41
- interface DocsGetInput {
42
- slug?: unknown;
43
- }
44
-
45
- interface DocHit {
46
- slug: string;
47
- title: string;
48
- path: string;
49
- excerpt: string;
50
- score: number;
51
- body?: string;
52
- }
53
-
54
- interface DocsSearchResult {
55
- query: string;
56
- scope: Scope;
57
- results: DocHit[];
58
- totalMatched: number;
59
- note?: string;
60
- }
61
-
62
- interface DocsGetResult {
63
- slug: string;
64
- path: string;
65
- title: string;
66
- body: string;
67
- note?: string;
68
- }
69
-
70
- // ─────────────────────────────────────────────────────────────────────────
71
- // Input validation
72
- // ─────────────────────────────────────────────────────────────────────────
73
-
74
- function validateSearch(raw: Record<string, unknown>): {
75
- ok: true; query: string; scope: Scope; limit: number; includeBody: boolean;
76
- } | { ok: false; error: string; field: string } {
77
- const q = raw.query;
78
- if (typeof q !== "string" || q.trim().length === 0) {
79
- return { ok: false, error: "'query' must be a non-empty string", field: "query" };
80
- }
81
- const scope = raw.scope ?? "all";
82
- if (typeof scope !== "string") {
83
- return { ok: false, error: "'scope' must be a string or omitted", field: "scope" };
84
- }
85
- let limit = DEFAULT_LIMIT;
86
- if (raw.limit !== undefined) {
87
- if (typeof raw.limit !== "number" || !Number.isFinite(raw.limit) || raw.limit < 1) {
88
- return { ok: false, error: "'limit' must be a positive number", field: "limit" };
89
- }
90
- limit = Math.min(Math.floor(raw.limit), MAX_LIMIT);
91
- }
92
- const includeBody = raw.includeBody === true;
93
- return { ok: true, query: q.trim(), scope, limit, includeBody };
94
- }
95
-
96
- function validateGet(raw: Record<string, unknown>): {
97
- ok: true; slug: string;
98
- } | { ok: false; error: string; field: string } {
99
- const s = raw.slug;
100
- if (typeof s !== "string" || s.trim().length === 0) {
101
- return { ok: false, error: "'slug' must be a non-empty string", field: "slug" };
102
- }
103
- // Block traversal explicitly — the handler joins against `docs/` and
104
- // would otherwise happily read /etc/passwd on Unix.
105
- if (s.includes("..") || path.isAbsolute(s)) {
106
- return { ok: false, error: "'slug' must be a relative docs path without '..'", field: "slug" };
107
- }
108
- return { ok: true, slug: s.trim() };
109
- }
110
-
111
- // ─────────────────────────────────────────────────────────────────────────
112
- // Indexing + search
113
- // ─────────────────────────────────────────────────────────────────────────
114
-
115
- async function walkDocs(rootDir: string, relPrefix = ""): Promise<string[]> {
116
- const out: string[] = [];
117
- let entries: __ManduNodeFsTypes0.Dirent[];
118
- try {
119
- entries = await fs.readdir(rootDir, { withFileTypes: true });
120
- } catch {
121
- return out;
122
- }
123
- for (const entry of entries) {
124
- if (out.length >= MAX_FILES) break;
125
- const absPath = path.join(rootDir, entry.name);
126
- const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
127
- if (entry.isDirectory()) {
128
- // Skip generated / hidden noise.
129
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
130
- const nested = await walkDocs(absPath, relPath);
131
- out.push(...nested);
132
- } else if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) {
133
- out.push(relPath);
134
- }
135
- }
136
- return out;
137
- }
138
-
139
- /**
140
- * Extract a human-readable title. Preference order:
141
- * 1. `title:` frontmatter field
142
- * 2. First `# heading` line
143
- * 3. Slug basename
144
- */
145
- function extractTitle(body: string, fallback: string): string {
146
- // Frontmatter block (--- ... ---) — scan for `title:` only.
147
- if (body.startsWith("---")) {
148
- const end = body.indexOf("\n---", 3);
149
- if (end > 0) {
150
- const front = body.slice(3, end);
151
- const m = /\btitle\s*:\s*["']?([^"\n\r]+?)["']?\s*$/m.exec(front);
152
- if (m?.[1]) return m[1].trim();
153
- }
154
- }
155
- const h = /^#\s+(.+)$/m.exec(body);
156
- if (h?.[1]) return h[1].trim();
157
- return path.basename(fallback).replace(/\.(md|mdx)$/, "");
158
- }
159
-
160
- /**
161
- * Cheap, deterministic scoring: +3 for every title hit, +1 per body hit,
162
- * tie-broken by shorter path (shorter paths are usually more canonical).
163
- */
164
- function scoreDoc(query: string, title: string, body: string, pathLen: number): {
165
- score: number;
166
- matchedInTitle: boolean;
167
- firstHitOffset: number;
168
- } {
169
- const q = query.toLowerCase();
170
- const titleLower = title.toLowerCase();
171
- const bodyLower = body.toLowerCase();
172
- let score = 0;
173
- const matchedInTitle = titleLower.includes(q);
174
- if (matchedInTitle) score += 3;
175
- const firstHit = bodyLower.indexOf(q);
176
- if (firstHit >= 0) score += 1;
177
- // Every extra hit (bounded at 10 to avoid abuse by repeated-word pages).
178
- let idx = firstHit;
179
- let extra = 0;
180
- while (idx >= 0 && extra < 9) {
181
- const next = bodyLower.indexOf(q, idx + q.length);
182
- if (next < 0) break;
183
- extra += 1;
184
- idx = next;
185
- }
186
- score += extra;
187
- // Tiebreaker — shorter paths float up. Encoded as a small negative so
188
- // it never eclipses a body-hit delta.
189
- score += 1 / (pathLen + 10);
190
- return { score, matchedInTitle, firstHitOffset: firstHit };
191
- }
192
-
193
- function excerptAround(body: string, hitOffset: number): string {
194
- if (hitOffset < 0) return body.slice(0, MAX_SNIPPET_CHARS).replace(/\s+/g, " ").trim();
195
- const start = Math.max(0, hitOffset - Math.floor(MAX_SNIPPET_CHARS / 3));
196
- const end = Math.min(body.length, start + MAX_SNIPPET_CHARS);
197
- const slice = body.slice(start, end).replace(/\s+/g, " ").trim();
198
- return (start > 0 ? "…" : "") + slice + (end < body.length ? "…" : "");
199
- }
200
-
201
- async function searchDocs(projectRoot: string, input: {
202
- query: string; scope: Scope; limit: number; includeBody: boolean;
203
- }): Promise<DocsSearchResult> {
204
- const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
205
- const files = await walkDocs(docsRoot);
206
- if (files.length === 0) {
207
- return {
208
- query: input.query,
209
- scope: input.scope,
210
- results: [],
211
- totalMatched: 0,
212
- note: `No files under ${DOCS_DIR_NAME}/ — project has no local docs tree indexed yet.`,
213
- };
214
- }
215
-
216
- const scopeFilter = input.scope === "all"
217
- ? null
218
- : new RegExp(`^${input.scope.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|$)`);
219
-
220
- const hits: DocHit[] = [];
221
- for (const rel of files) {
222
- if (scopeFilter && !scopeFilter.test(rel)) continue;
223
- const abs = path.join(docsRoot, rel);
224
- let body: string;
225
- try {
226
- body = await fs.readFile(abs, "utf-8");
227
- } catch {
228
- continue;
229
- }
230
- const title = extractTitle(body, rel);
231
- const { score, firstHitOffset } = scoreDoc(input.query, title, body, rel.length);
232
- if (score < 1) continue;
233
- const hit: DocHit = {
234
- slug: rel,
235
- title,
236
- path: path.join(DOCS_DIR_NAME, rel),
237
- excerpt: excerptAround(body, firstHitOffset),
238
- score: Math.round(score * 1000) / 1000,
239
- };
240
- if (input.includeBody) hit.body = body;
241
- hits.push(hit);
242
- }
243
-
244
- hits.sort((a, b) => b.score - a.score);
245
- const totalMatched = hits.length;
246
- return {
247
- query: input.query,
248
- scope: input.scope,
249
- results: hits.slice(0, input.limit),
250
- totalMatched,
251
- };
252
- }
253
-
254
- async function getDoc(projectRoot: string, slug: string): Promise<DocsGetResult> {
255
- const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
256
- const abs = path.join(docsRoot, slug);
257
- // Guard — ensure resolved path still sits inside docsRoot after
258
- // normalization. Defense in depth vs. the `..` check in validation.
259
- if (!path.resolve(abs).startsWith(path.resolve(docsRoot) + path.sep)) {
260
- return {
261
- slug,
262
- path: "",
263
- title: "",
264
- body: "",
265
- note: "Resolved path escaped docs/ — refused.",
266
- };
267
- }
268
- let body: string;
269
- try {
270
- body = await fs.readFile(abs, "utf-8");
271
- } catch (err) {
272
- return {
273
- slug,
274
- path: path.join(DOCS_DIR_NAME, slug),
275
- title: path.basename(slug).replace(/\.(md|mdx)$/, ""),
276
- body: "",
277
- note: `Read failed: ${err instanceof Error ? err.message : String(err)}`,
278
- };
279
- }
280
- return {
281
- slug,
282
- path: path.join(DOCS_DIR_NAME, slug),
283
- title: extractTitle(body, slug),
284
- body,
285
- };
286
- }
287
-
288
- // ─────────────────────────────────────────────────────────────────────────
289
- // MCP tool definitions + handler map
290
- // ─────────────────────────────────────────────────────────────────────────
291
-
292
- export const docsToolDefinitions: Tool[] = [
293
- {
294
- name: "mandu.docs.search",
295
- description:
296
- "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.",
297
- annotations: { readOnlyHint: true },
298
- inputSchema: {
299
- type: "object",
300
- properties: {
301
- query: { type: "string", description: "Search text. Matches title + body (case-insensitive)." },
302
- scope: {
303
- type: "string",
304
- description: "Restrict search to a `docs/` subdirectory (e.g. 'architect', 'bun'). Default 'all'.",
305
- },
306
- limit: {
307
- type: "number",
308
- description: `Maximum results returned. Default ${DEFAULT_LIMIT}, capped at ${MAX_LIMIT}.`,
309
- },
310
- includeBody: {
311
- type: "boolean",
312
- description: "Return the full markdown body for each hit. Default false (excerpt only).",
313
- },
314
- },
315
- required: ["query"],
316
- },
317
- },
318
- {
319
- name: "mandu.docs.get",
320
- description:
321
- "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.",
322
- annotations: { readOnlyHint: true },
323
- inputSchema: {
324
- type: "object",
325
- properties: {
326
- slug: {
327
- type: "string",
328
- description: "Relative path under docs/ (no leading slash, no '..'). Example: 'architect/rendering-modes.md'.",
329
- },
330
- },
331
- required: ["slug"],
332
- },
333
- },
334
- ];
335
-
336
- export function docsTools(projectRoot: string) {
337
- const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
338
- "mandu.docs.search": async (args) => {
339
- const v = validateSearch(args as DocsSearchInput as Record<string, unknown>);
340
- if (!v.ok) return { error: v.error, field: v.field };
341
- return searchDocs(projectRoot, v);
342
- },
343
- "mandu.docs.get": async (args) => {
344
- const v = validateGet(args as DocsGetInput as Record<string, unknown>);
345
- if (!v.ok) return { error: v.error, field: v.field };
346
- return getDoc(projectRoot, v.slug);
347
- },
348
- };
349
- return handlers;
350
- }
1
+ import type * as __ManduNodeFsTypes0 from "node:fs";
2
+ /**
3
+ * MCP tools — `mandu.docs.search` + `mandu.docs.get`
4
+ *
5
+ * Issue #243 — agents had 100+ MCP tools but none pointed at the Mandu
6
+ * docs tree. These two tools close that gap by indexing the project's
7
+ * `docs/` directory (where the framework's own markdown lives) and
8
+ * returning search hits + full page bodies.
9
+ *
10
+ * Implementation is deliberately offline-first: we walk `docs/` with
11
+ * `fs.readdir` and grep by title / frontmatter / body. No Pagefind, no
12
+ * network, no extra dependencies — good for any repo that vendors the
13
+ * Mandu docs or has its own markdown tree under `docs/`.
14
+ *
15
+ * Invariants:
16
+ * - Read-only. Never writes files.
17
+ * - Bounded: at most `MAX_FILES` files walked, `MAX_SNIPPET_CHARS` per
18
+ * excerpt. A docs tree of ~10k files would otherwise OOM the handler.
19
+ * - Fails soft: missing `docs/` returns `{ results: [], note: "…" }`.
20
+ */
21
+
22
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
23
+ import path from "path";
24
+ import fs from "fs/promises";
25
+
26
+ const DOCS_DIR_NAME = "docs";
27
+ const MAX_FILES = 5_000;
28
+ const MAX_SNIPPET_CHARS = 280;
29
+ const DEFAULT_LIMIT = 5;
30
+ const MAX_LIMIT = 25;
31
+
32
+ type Scope = "all" | string;
33
+
34
+ interface DocsSearchInput {
35
+ query?: unknown;
36
+ scope?: unknown;
37
+ limit?: unknown;
38
+ includeBody?: unknown;
39
+ }
40
+
41
+ interface DocsGetInput {
42
+ slug?: unknown;
43
+ }
44
+
45
+ interface DocHit {
46
+ slug: string;
47
+ title: string;
48
+ path: string;
49
+ excerpt: string;
50
+ score: number;
51
+ body?: string;
52
+ }
53
+
54
+ interface DocsSearchResult {
55
+ query: string;
56
+ scope: Scope;
57
+ results: DocHit[];
58
+ totalMatched: number;
59
+ note?: string;
60
+ }
61
+
62
+ interface DocsGetResult {
63
+ slug: string;
64
+ path: string;
65
+ title: string;
66
+ body: string;
67
+ note?: string;
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+ // Input validation
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+
74
+ function validateSearch(raw: Record<string, unknown>): {
75
+ ok: true; query: string; scope: Scope; limit: number; includeBody: boolean;
76
+ } | { ok: false; error: string; field: string } {
77
+ const q = raw.query;
78
+ if (typeof q !== "string" || q.trim().length === 0) {
79
+ return { ok: false, error: "'query' must be a non-empty string", field: "query" };
80
+ }
81
+ const scope = raw.scope ?? "all";
82
+ if (typeof scope !== "string") {
83
+ return { ok: false, error: "'scope' must be a string or omitted", field: "scope" };
84
+ }
85
+ let limit = DEFAULT_LIMIT;
86
+ if (raw.limit !== undefined) {
87
+ if (typeof raw.limit !== "number" || !Number.isFinite(raw.limit) || raw.limit < 1) {
88
+ return { ok: false, error: "'limit' must be a positive number", field: "limit" };
89
+ }
90
+ limit = Math.min(Math.floor(raw.limit), MAX_LIMIT);
91
+ }
92
+ const includeBody = raw.includeBody === true;
93
+ return { ok: true, query: q.trim(), scope, limit, includeBody };
94
+ }
95
+
96
+ function validateGet(raw: Record<string, unknown>): {
97
+ ok: true; slug: string;
98
+ } | { ok: false; error: string; field: string } {
99
+ const s = raw.slug;
100
+ if (typeof s !== "string" || s.trim().length === 0) {
101
+ return { ok: false, error: "'slug' must be a non-empty string", field: "slug" };
102
+ }
103
+ // Block traversal explicitly — the handler joins against `docs/` and
104
+ // would otherwise happily read /etc/passwd on Unix.
105
+ if (s.includes("..") || path.isAbsolute(s)) {
106
+ return { ok: false, error: "'slug' must be a relative docs path without '..'", field: "slug" };
107
+ }
108
+ return { ok: true, slug: s.trim() };
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────
112
+ // Indexing + search
113
+ // ─────────────────────────────────────────────────────────────────────────
114
+
115
+ async function walkDocs(rootDir: string, relPrefix = ""): Promise<string[]> {
116
+ const out: string[] = [];
117
+ let entries: __ManduNodeFsTypes0.Dirent[];
118
+ try {
119
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
120
+ } catch {
121
+ return out;
122
+ }
123
+ for (const entry of entries) {
124
+ if (out.length >= MAX_FILES) break;
125
+ const absPath = path.join(rootDir, entry.name);
126
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
127
+ if (entry.isDirectory()) {
128
+ // Skip generated / hidden noise.
129
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
130
+ const nested = await walkDocs(absPath, relPath);
131
+ out.push(...nested);
132
+ } else if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) {
133
+ out.push(relPath);
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * Extract a human-readable title. Preference order:
141
+ * 1. `title:` frontmatter field
142
+ * 2. First `# heading` line
143
+ * 3. Slug basename
144
+ */
145
+ function extractTitle(body: string, fallback: string): string {
146
+ // Frontmatter block (--- ... ---) — scan for `title:` only.
147
+ if (body.startsWith("---")) {
148
+ const end = body.indexOf("\n---", 3);
149
+ if (end > 0) {
150
+ const front = body.slice(3, end);
151
+ const m = /\btitle\s*:\s*["']?([^"\n\r]+?)["']?\s*$/m.exec(front);
152
+ if (m?.[1]) return m[1].trim();
153
+ }
154
+ }
155
+ const h = /^#\s+(.+)$/m.exec(body);
156
+ if (h?.[1]) return h[1].trim();
157
+ return path.basename(fallback).replace(/\.(md|mdx)$/, "");
158
+ }
159
+
160
+ /**
161
+ * Cheap, deterministic scoring: +3 for every title hit, +1 per body hit,
162
+ * tie-broken by shorter path (shorter paths are usually more canonical).
163
+ */
164
+ function scoreDoc(query: string, title: string, body: string, pathLen: number): {
165
+ score: number;
166
+ matchedInTitle: boolean;
167
+ firstHitOffset: number;
168
+ } {
169
+ const q = query.toLowerCase();
170
+ const titleLower = title.toLowerCase();
171
+ const bodyLower = body.toLowerCase();
172
+ let score = 0;
173
+ const matchedInTitle = titleLower.includes(q);
174
+ if (matchedInTitle) score += 3;
175
+ const firstHit = bodyLower.indexOf(q);
176
+ if (firstHit >= 0) score += 1;
177
+ // Every extra hit (bounded at 10 to avoid abuse by repeated-word pages).
178
+ let idx = firstHit;
179
+ let extra = 0;
180
+ while (idx >= 0 && extra < 9) {
181
+ const next = bodyLower.indexOf(q, idx + q.length);
182
+ if (next < 0) break;
183
+ extra += 1;
184
+ idx = next;
185
+ }
186
+ score += extra;
187
+ // Tiebreaker — shorter paths float up. Encoded as a small negative so
188
+ // it never eclipses a body-hit delta.
189
+ score += 1 / (pathLen + 10);
190
+ return { score, matchedInTitle, firstHitOffset: firstHit };
191
+ }
192
+
193
+ function excerptAround(body: string, hitOffset: number): string {
194
+ if (hitOffset < 0) return body.slice(0, MAX_SNIPPET_CHARS).replace(/\s+/g, " ").trim();
195
+ const start = Math.max(0, hitOffset - Math.floor(MAX_SNIPPET_CHARS / 3));
196
+ const end = Math.min(body.length, start + MAX_SNIPPET_CHARS);
197
+ const slice = body.slice(start, end).replace(/\s+/g, " ").trim();
198
+ return (start > 0 ? "…" : "") + slice + (end < body.length ? "…" : "");
199
+ }
200
+
201
+ async function searchDocs(projectRoot: string, input: {
202
+ query: string; scope: Scope; limit: number; includeBody: boolean;
203
+ }): Promise<DocsSearchResult> {
204
+ const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
205
+ const files = await walkDocs(docsRoot);
206
+ if (files.length === 0) {
207
+ return {
208
+ query: input.query,
209
+ scope: input.scope,
210
+ results: [],
211
+ totalMatched: 0,
212
+ note: `No files under ${DOCS_DIR_NAME}/ — project has no local docs tree indexed yet.`,
213
+ };
214
+ }
215
+
216
+ const scopeFilter = input.scope === "all"
217
+ ? null
218
+ : new RegExp(`^${input.scope.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|$)`);
219
+
220
+ const hits: DocHit[] = [];
221
+ for (const rel of files) {
222
+ if (scopeFilter && !scopeFilter.test(rel)) continue;
223
+ const abs = path.join(docsRoot, rel);
224
+ let body: string;
225
+ try {
226
+ body = await fs.readFile(abs, "utf-8");
227
+ } catch {
228
+ continue;
229
+ }
230
+ const title = extractTitle(body, rel);
231
+ const { score, firstHitOffset } = scoreDoc(input.query, title, body, rel.length);
232
+ if (score < 1) continue;
233
+ const hit: DocHit = {
234
+ slug: rel,
235
+ title,
236
+ path: path.join(DOCS_DIR_NAME, rel),
237
+ excerpt: excerptAround(body, firstHitOffset),
238
+ score: Math.round(score * 1000) / 1000,
239
+ };
240
+ if (input.includeBody) hit.body = body;
241
+ hits.push(hit);
242
+ }
243
+
244
+ hits.sort((a, b) => b.score - a.score);
245
+ const totalMatched = hits.length;
246
+ return {
247
+ query: input.query,
248
+ scope: input.scope,
249
+ results: hits.slice(0, input.limit),
250
+ totalMatched,
251
+ };
252
+ }
253
+
254
+ async function getDoc(projectRoot: string, slug: string): Promise<DocsGetResult> {
255
+ const docsRoot = path.join(projectRoot, DOCS_DIR_NAME);
256
+ const abs = path.join(docsRoot, slug);
257
+ // Guard — ensure resolved path still sits inside docsRoot after
258
+ // normalization. Defense in depth vs. the `..` check in validation.
259
+ if (!path.resolve(abs).startsWith(path.resolve(docsRoot) + path.sep)) {
260
+ return {
261
+ slug,
262
+ path: "",
263
+ title: "",
264
+ body: "",
265
+ note: "Resolved path escaped docs/ — refused.",
266
+ };
267
+ }
268
+ let body: string;
269
+ try {
270
+ body = await fs.readFile(abs, "utf-8");
271
+ } catch (err) {
272
+ return {
273
+ slug,
274
+ path: path.join(DOCS_DIR_NAME, slug),
275
+ title: path.basename(slug).replace(/\.(md|mdx)$/, ""),
276
+ body: "",
277
+ note: `Read failed: ${err instanceof Error ? err.message : String(err)}`,
278
+ };
279
+ }
280
+ return {
281
+ slug,
282
+ path: path.join(DOCS_DIR_NAME, slug),
283
+ title: extractTitle(body, slug),
284
+ body,
285
+ };
286
+ }
287
+
288
+ // ─────────────────────────────────────────────────────────────────────────
289
+ // MCP tool definitions + handler map
290
+ // ─────────────────────────────────────────────────────────────────────────
291
+
292
+ export const docsToolDefinitions: Tool[] = [
293
+ {
294
+ name: "mandu.docs.search",
295
+ description:
296
+ "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.",
297
+ annotations: { readOnlyHint: true },
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ query: { type: "string", description: "Search text. Matches title + body (case-insensitive)." },
302
+ scope: {
303
+ type: "string",
304
+ description: "Restrict search to a `docs/` subdirectory (e.g. 'architect', 'bun'). Default 'all'.",
305
+ },
306
+ limit: {
307
+ type: "number",
308
+ description: `Maximum results returned. Default ${DEFAULT_LIMIT}, capped at ${MAX_LIMIT}.`,
309
+ },
310
+ includeBody: {
311
+ type: "boolean",
312
+ description: "Return the full markdown body for each hit. Default false (excerpt only).",
313
+ },
314
+ },
315
+ required: ["query"],
316
+ },
317
+ },
318
+ {
319
+ name: "mandu.docs.get",
320
+ description:
321
+ "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.",
322
+ annotations: { readOnlyHint: true },
323
+ inputSchema: {
324
+ type: "object",
325
+ properties: {
326
+ slug: {
327
+ type: "string",
328
+ description: "Relative path under docs/ (no leading slash, no '..'). Example: 'architect/rendering-modes.md'.",
329
+ },
330
+ },
331
+ required: ["slug"],
332
+ },
333
+ },
334
+ ];
335
+
336
+ export function docsTools(projectRoot: string) {
337
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
338
+ "mandu.docs.search": async (args) => {
339
+ const v = validateSearch(args as DocsSearchInput as Record<string, unknown>);
340
+ if (!v.ok) return { error: v.error, field: v.field };
341
+ return searchDocs(projectRoot, v);
342
+ },
343
+ "mandu.docs.get": async (args) => {
344
+ const v = validateGet(args as DocsGetInput as Record<string, unknown>);
345
+ if (!v.ok) return { error: v.error, field: v.field };
346
+ return getDoc(projectRoot, v.slug);
347
+ },
348
+ };
349
+ return handlers;
350
+ }