@nifrajs/cli 3.1.0 → 3.2.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.
Files changed (158) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +36 -0
  122. package/docs/llms-full.txt +1226 -14
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
@@ -0,0 +1,1040 @@
1
+ /**
2
+ * Shared project-tool execution for stdio, run, and render.
3
+ *
4
+ * This module owns child-process limits, warm-worker lifecycle, source fingerprinting, and cached app
5
+ * loading. It has no JSON-RPC transport or root-trust policy, so those seams remain independently
6
+ * replaceable.
7
+ */
8
+
9
+ import { stat } from "node:fs/promises"
10
+ import { resolve, sep } from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+ import { Glob } from "bun"
13
+ import {
14
+ type CommandCatalogEntry,
15
+ type CommandCtx,
16
+ type CommandSpec,
17
+ commandCatalog,
18
+ commandMcpName,
19
+ findCommandSpec,
20
+ } from "./command-catalog.ts"
21
+ import { collectContractProof } from "./contract-proof.ts"
22
+ import { loadDocsCorpus } from "./docs-search.ts"
23
+ import { loadExamplesCorpus } from "./examples.ts"
24
+ import type { LoadAppOptions, LoadedApp } from "./load.ts"
25
+ import {
26
+ type CommandMcpToolOptions,
27
+ dirError,
28
+ openApiHandler,
29
+ resolveProjectDir,
30
+ } from "./mcp-context.ts"
31
+ import { docsTools } from "./mcp-docs-tools.ts"
32
+ import {
33
+ CHILD_OUTPUT_MAX_BYTES,
34
+ CHILD_TIMEOUT_MS,
35
+ LOCAL_TOOL_FETCH_TIMEOUT_MS,
36
+ notNifraResponse,
37
+ readBoundedResponse,
38
+ readBoundedStream,
39
+ timeoutMessage,
40
+ validateLocalPort,
41
+ } from "./mcp-io.ts"
42
+ import type { McpTool, McpToolContext } from "./mcp-protocol.ts"
43
+ import { loadTypesCorpus } from "./types-search.ts"
44
+
45
+ /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
46
+ * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
47
+
48
+ export function toMcpTool(
49
+ source: CommandSpec<unknown, unknown> | CommandCatalogEntry,
50
+ options: CommandMcpToolOptions,
51
+ ): McpTool {
52
+ const spec = "run" in source ? source : findCommandSpec(source.name)
53
+ if (spec === undefined) throw new Error(`missing command spec for ${source.name}`)
54
+ const entry = "run" in source ? undefined : source
55
+ const { cwd } = options
56
+ const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
57
+ return {
58
+ name: commandMcpName(entry?.name ?? spec.name),
59
+ description: entry?.summary ?? spec.summary,
60
+ inputSchema: entry?.inputSchema ?? spec.input.jsonSchema,
61
+ handler: async (args: Record<string, unknown>, context: McpToolContext) => {
62
+ const raw = { ...args }
63
+ const dir = raw.dir
64
+ if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
65
+ const target = resolveProjectDir(cwd, dir as string | undefined)
66
+ if (target === null) return dirError(dir as string | undefined)
67
+ if (typeof raw.config === "string") {
68
+ const config = resolve(target, raw.config)
69
+ if (config !== target && !config.startsWith(`${target}${sep}`))
70
+ return JSON.stringify(
71
+ { ok: false, error: "config must stay inside the selected project directory" },
72
+ null,
73
+ 2,
74
+ )
75
+ }
76
+ delete raw.dir
77
+ let input: unknown
78
+ try {
79
+ input = spec.input.parse(raw)
80
+ } catch (error) {
81
+ return JSON.stringify(
82
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
83
+ null,
84
+ 2,
85
+ )
86
+ }
87
+ const commandContext: CommandCtx = {
88
+ cwd: target,
89
+ signal: context.signal,
90
+ progress: () => context.reportProgress?.(0.5, 1),
91
+ ...(target === cwd ? { loadApp: loadAppCached } : {}),
92
+ }
93
+ const output = await spec.run(input, commandContext)
94
+ const value = spec.json?.(output, input) ?? output
95
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2)
96
+ },
97
+ }
98
+ }
99
+
100
+ export function catalogProjectTools(
101
+ cwd: string,
102
+ loadAppCached: () => Promise<LoadedApp> = createCachedAppLoader(cwd),
103
+ ): McpTool[] {
104
+ return commandCatalog
105
+ .filter((entry) => entry.transports.includes("mcp"))
106
+ .map((entry) => {
107
+ const spec = findCommandSpec(entry.name)
108
+ if (spec === undefined) throw new Error(`missing command spec for ${entry.name}`)
109
+ return toMcpTool(spec, { cwd, loadAppCached })
110
+ })
111
+ }
112
+
113
+ export function projectTools(
114
+ cwd: string,
115
+ loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
116
+ ): McpTool[] {
117
+ const warmRun = createWarmHandler("mcp-run", cwd, "run")
118
+ const warmRender = createWarmHandler("mcp-render", cwd, "render")
119
+ const catalogTools = catalogProjectTools(cwd, () => loadAppCached())
120
+ const legacyTools: McpTool[] = [
121
+ {
122
+ name: "nifra_verify",
123
+ description:
124
+ "Run the shared repository verification plan. Set release=true for the full release plan; the response preserves the declarative gate order and each gate's remediation.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ release: {
129
+ type: "boolean",
130
+ description: "Run the full release plan instead of the default plan.",
131
+ },
132
+ dir: {
133
+ type: "string",
134
+ description: "Project subdirectory to resolve the repository from.",
135
+ },
136
+ },
137
+ additionalProperties: false,
138
+ },
139
+ handler: async (args) => {
140
+ const raw = args as { release?: unknown; dir?: unknown }
141
+ if (raw.release !== undefined && typeof raw.release !== "boolean") {
142
+ return JSON.stringify({ ok: false, error: "release must be a boolean" }, null, 2)
143
+ }
144
+ if (raw.dir !== undefined && typeof raw.dir !== "string") {
145
+ return JSON.stringify({ ok: false, error: "dir must be a string" }, null, 2)
146
+ }
147
+ const target = resolveProjectDir(cwd, raw.dir as string | undefined)
148
+ if (target === null) return dirError(raw.dir as string | undefined)
149
+ const { collectReleaseVerification } = await import("./release-verification.ts")
150
+ return JSON.stringify(
151
+ await collectReleaseVerification(target, {
152
+ mode: raw.release === true ? "release" : "default",
153
+ }),
154
+ null,
155
+ 2,
156
+ )
157
+ },
158
+ },
159
+ {
160
+ name: "nifra_contract_proof",
161
+ description:
162
+ "Compare the current route contract with its snapshot and join each changed route to its route-assurance and capability evidence. Returns hasBreaking as the gate. The typed-contract check is lazy and runs only when check is true.",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ baseline: {
167
+ type: "string",
168
+ description:
169
+ "Snapshot path relative to the selected project directory. Defaults to api-snapshot.json.",
170
+ },
171
+ check: {
172
+ type: "boolean",
173
+ description: "Also run the typed-contract check. Default false to keep the diff lazy.",
174
+ },
175
+ dir: {
176
+ type: "string",
177
+ description: "Project subdirectory to inspect.",
178
+ },
179
+ },
180
+ additionalProperties: false,
181
+ },
182
+ handler: async (args) => {
183
+ const raw = args as { baseline?: unknown; check?: unknown; dir?: unknown }
184
+ if (raw.dir !== undefined && typeof raw.dir !== "string") {
185
+ return JSON.stringify({ ok: false, error: "dir must be a string" }, null, 2)
186
+ }
187
+ if (raw.baseline !== undefined && typeof raw.baseline !== "string") {
188
+ return JSON.stringify({ ok: false, error: "baseline must be a string" }, null, 2)
189
+ }
190
+ const dir = raw.dir as string | undefined
191
+ const baseline = raw.baseline as string | undefined
192
+ const target = resolveProjectDir(cwd, dir)
193
+ if (target === null) return dirError(dir)
194
+ if (baseline !== undefined && baseline.trim() === "") {
195
+ return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
196
+ }
197
+ try {
198
+ return JSON.stringify(
199
+ await collectContractProof(target, {
200
+ ...(baseline === undefined ? {} : { baselinePath: baseline }),
201
+ ...(raw.check === undefined ? {} : { check: raw.check === true }),
202
+ }),
203
+ null,
204
+ 2,
205
+ )
206
+ } catch (error) {
207
+ return JSON.stringify(
208
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
209
+ null,
210
+ 2,
211
+ )
212
+ }
213
+ },
214
+ },
215
+ {
216
+ name: "nifra_explain",
217
+ description:
218
+ "Turn a nifra error into a STRUCTURED diagnostic instead of eyeballing a stack trace: a stable `code`, the top frame in YOUR source, a codeframe around the offending line, and - when nifra recognises the failure - the plain-language `cause` + `fix` + docs anchor. Pass `error` (and `stack` if you have it, e.g. from nifra_run/nifra_test output or a failing build) to explain a specific failure; or pass `port` to fetch the running dev server's most recent SSR failure from `/__nifra/last-error`. Returns the same JSON the dev overlay renders.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ error: {
223
+ type: "string",
224
+ description:
225
+ "The error message to explain (copy it from nifra_run/nifra_test output or a failing build).",
226
+ },
227
+ stack: {
228
+ type: "string",
229
+ description:
230
+ "The error's stack trace, if you have it - enables the source codeframe and the top user frame.",
231
+ },
232
+ name: {
233
+ type: "string",
234
+ description:
235
+ "The error's class name (e.g. TypeError, SchemaError), if known - sharpens classification.",
236
+ },
237
+ port: {
238
+ type: "number",
239
+ description:
240
+ "Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
241
+ },
242
+ },
243
+ additionalProperties: false,
244
+ },
245
+ handler: async (args) => {
246
+ const { error, stack, name, port } = args as {
247
+ error?: string
248
+ stack?: string
249
+ name?: string
250
+ port?: number
251
+ }
252
+ if (error !== undefined || stack !== undefined) {
253
+ const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
254
+ const e = new Error(error ?? "")
255
+ if (name !== undefined) e.name = name
256
+ if (stack !== undefined) e.stack = stack
257
+ return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
258
+ }
259
+ if (port !== undefined) {
260
+ const validPort = validateLocalPort(port)
261
+ if (validPort === undefined) {
262
+ return JSON.stringify(
263
+ { code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
264
+ null,
265
+ 2,
266
+ )
267
+ }
268
+ const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
269
+ try {
270
+ const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
271
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
272
+ })
273
+ if (res.headers.get("x-nifra-diagnostic") !== "true")
274
+ return notNifraResponse("diagnostic")
275
+ if (!res.ok) {
276
+ return JSON.stringify(
277
+ {
278
+ code: "NIFRA_NONE",
279
+ message: `dev server at :${validPort} returned ${res.status}`,
280
+ },
281
+ null,
282
+ 2,
283
+ )
284
+ }
285
+ return await readBoundedResponse(res)
286
+ } catch (cause) {
287
+ return JSON.stringify(
288
+ {
289
+ code: "NIFRA_NONE",
290
+ message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
291
+ },
292
+ null,
293
+ 2,
294
+ )
295
+ }
296
+ }
297
+ return JSON.stringify(
298
+ {
299
+ code: "NIFRA_NONE",
300
+ message:
301
+ "Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
302
+ },
303
+ null,
304
+ 2,
305
+ )
306
+ },
307
+ },
308
+ {
309
+ name: "nifra_inspect",
310
+ description:
311
+ "Observe what your requests ACTUALLY did on the running dev server - the recent request traces the DevTools plugin records: `{ method, path, status, durationMs, isrStatus, bodyBytes }` per request. The read no other tool gives you: after nifra_run or a real browser request, call this to SEE the outcome (which route answered, the status, how long, ISR hit/miss) instead of guessing. Pass `port` (the running dev server); narrow with `path` (a path prefix) or `limit` (most recent N). Requires the app to mount `@nifrajs/web`'s `devtools()` plugin (which auto-enables in development).",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: {
315
+ port: { type: "number", description: "The running dev server's port." },
316
+ path: { type: "string", description: "Only traces whose path starts with this prefix." },
317
+ limit: { type: "number", description: "Return only the most recent N traces." },
318
+ },
319
+ required: ["port"],
320
+ additionalProperties: false,
321
+ },
322
+ handler: async (args) => {
323
+ const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
324
+ if (port === undefined) {
325
+ return JSON.stringify(
326
+ {
327
+ events: [],
328
+ note: "Pass `port` - the running dev server whose request traces to read.",
329
+ },
330
+ null,
331
+ 2,
332
+ )
333
+ }
334
+ try {
335
+ const validPort = validateLocalPort(port)
336
+ if (validPort === undefined) {
337
+ return JSON.stringify(
338
+ {
339
+ events: [],
340
+ code: "NIFRA_INVALID_PORT",
341
+ note: "port must be an integer from 1 to 65535.",
342
+ },
343
+ null,
344
+ 2,
345
+ )
346
+ }
347
+ const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
348
+ if (path !== undefined) url.searchParams.set("path", path)
349
+ if (limit !== undefined) url.searchParams.set("limit", String(limit))
350
+ const res = await fetch(url, {
351
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
352
+ })
353
+ if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
354
+ if (res.status === 404) {
355
+ return JSON.stringify(
356
+ {
357
+ events: [],
358
+ note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
359
+ },
360
+ null,
361
+ 2,
362
+ )
363
+ }
364
+ if (!res.ok) {
365
+ return JSON.stringify(
366
+ { events: [], note: `DevTools state returned ${res.status}.` },
367
+ null,
368
+ 2,
369
+ )
370
+ }
371
+ return await readBoundedResponse(res)
372
+ } catch (cause) {
373
+ const validPort = validateLocalPort(port)
374
+ return JSON.stringify(
375
+ {
376
+ events: [],
377
+ note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
378
+ },
379
+ null,
380
+ 2,
381
+ )
382
+ }
383
+ },
384
+ },
385
+ {
386
+ name: "nifra_openapi",
387
+ description:
388
+ 'Return this project\'s backend OpenAPI 3.1 document generated from backend.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Pass `path` (a route prefix like /api/orders, mirroring nifra_routes) to narrow a large backend to operations under that prefix instead of the whole document. Frontend-only apps return a valid empty paths object.',
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: {
392
+ format: {
393
+ type: "string",
394
+ enum: ["json", "yaml"],
395
+ description: "Output format (default: json).",
396
+ },
397
+ path: {
398
+ type: "string",
399
+ description:
400
+ "Only operations whose path starts with this prefix (omit for the whole document).",
401
+ },
402
+ },
403
+ additionalProperties: false,
404
+ },
405
+ handler: (args) => openApiHandler(args, loadAppCached),
406
+ },
407
+ {
408
+ name: "nifra_run",
409
+ description:
410
+ "Run HTTP requests through this project's backend and return structured results (status, headers, parsed body, and any thrown error). Use it to verify code after editing: by default the backend is re-loaded in a fresh process each call. Pass warm:true to reuse a hot worker while source files are unchanged; it restarts automatically when files change. Each request: { method?, path, body?, headers? }.",
411
+ inputSchema: {
412
+ type: "object",
413
+ properties: {
414
+ requests: {
415
+ type: "array",
416
+ items: {
417
+ type: "object",
418
+ properties: {
419
+ method: { type: "string" },
420
+ path: { type: "string" },
421
+ body: {},
422
+ headers: { type: "object" },
423
+ },
424
+ required: ["path"],
425
+ },
426
+ },
427
+ entry: {
428
+ type: "string",
429
+ description: "Backend entry file (default: backend.ts | app.ts).",
430
+ },
431
+ warm: {
432
+ type: "boolean",
433
+ description:
434
+ "Reuse a hot backend worker while source files are unchanged. Default false for maximum isolation.",
435
+ },
436
+ },
437
+ required: ["requests"],
438
+ additionalProperties: false,
439
+ },
440
+ handler: (args, context) => runHandler(cwd, args, context, warmRun),
441
+ },
442
+ {
443
+ name: "nifra_render",
444
+ description:
445
+ "SSR a page route (routes/) through this project's CURRENT web app and return { status, headers, body: the rendered HTML }. The page half of nifra_run: use it to verify a page renders and its loader ran after an edit. No build needed (a placeholder client entry is used; the SSR HTML renders regardless). Each request: { path, headers? }. By default re-loaded in a fresh process each call; pass warm:true to reuse a hot worker while source files are unchanged (it restarts automatically when files change), mirroring nifra_run.",
446
+ inputSchema: {
447
+ type: "object",
448
+ properties: {
449
+ requests: {
450
+ type: "array",
451
+ items: {
452
+ type: "object",
453
+ properties: {
454
+ path: { type: "string" },
455
+ headers: { type: "object" },
456
+ },
457
+ required: ["path"],
458
+ },
459
+ },
460
+ warm: {
461
+ type: "boolean",
462
+ description:
463
+ "Reuse a hot web-app worker while source files are unchanged. Default false for maximum isolation.",
464
+ },
465
+ },
466
+ required: ["requests"],
467
+ additionalProperties: false,
468
+ },
469
+ handler: (args, context) => renderHandler(cwd, args, context, warmRender),
470
+ },
471
+ {
472
+ name: "nifra_ws",
473
+ description:
474
+ 'Verify a WebSocket route against this project by starting the backend on an ephemeral localhost port, opening a real Bun WebSocket, sending string frames, and returning structured evidence: { ok, opened, sent, received, close?, error? }. Use after adding or editing app.ws() routes. Pass path including query, e.g. "/chat?token=secret". By default expects one message when no messages are sent, or one response per sent message; set expectMessages:0 to verify connect-only routes.',
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ path: {
479
+ type: "string",
480
+ description:
481
+ 'App-local WebSocket path, optionally with query, e.g. "/chat?token=secret".',
482
+ },
483
+ messages: {
484
+ type: "array",
485
+ items: { type: "string" },
486
+ description: "String frames to send after the socket opens (max 50).",
487
+ },
488
+ expectMessages: {
489
+ type: "number",
490
+ description:
491
+ "How many inbound messages must be observed before success (default: messages.length, or 1 with no sent messages; max 50).",
492
+ },
493
+ timeoutMs: {
494
+ type: "number",
495
+ description: "Bounded verification timeout in milliseconds (default 3000, max 30000).",
496
+ },
497
+ entry: {
498
+ type: "string",
499
+ description: "Backend entry file (default: backend.ts | app.ts).",
500
+ },
501
+ },
502
+ required: ["path"],
503
+ additionalProperties: false,
504
+ },
505
+ handler: (args, context) => wsHandler(cwd, args, context),
506
+ },
507
+ {
508
+ name: "nifra_test",
509
+ description:
510
+ "Run `bun test` for this project and return bounded structured results: { ok, command, durationMs, exitCode, timedOut, summary, stdout, stderr }. Pass `pattern` to narrow to a test file/path; pass `timeoutMs` (default 30000, max 300000). Use after editing code, alongside nifra_run/nifra_render for behavioral checks.",
511
+ inputSchema: {
512
+ type: "object",
513
+ properties: {
514
+ pattern: {
515
+ type: "string",
516
+ description: "Optional test file/path pattern passed as an argv item to `bun test`.",
517
+ },
518
+ timeoutMs: {
519
+ type: "number",
520
+ description: "Timeout in milliseconds (default 30000, max 300000).",
521
+ },
522
+ dir: {
523
+ type: "string",
524
+ description:
525
+ 'Run the tests in this subdirectory (relative to the project root), e.g. "app". Use it when the MCP server\'s root is a monorepo but you want to test just one app. Default: the project root.',
526
+ },
527
+ },
528
+ additionalProperties: false,
529
+ },
530
+ handler: async (args, context) => {
531
+ const { collectTestResult } = await import("./test-tool.ts")
532
+ const opts = args as { dir?: string }
533
+ const target = resolveProjectDir(cwd, opts.dir)
534
+ if (target === null) return dirError(opts.dir)
535
+ return JSON.stringify(
536
+ await collectTestResult(target, args, { signal: context.signal }),
537
+ null,
538
+ 2,
539
+ )
540
+ },
541
+ },
542
+ // nifra_docs + nifra_example are project-independent (corpus-backed); the shared factory keeps their
543
+ // definitions identical to the CLI HTTP server and the site's edge worker.
544
+ ...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
545
+ {
546
+ name: "nifra_scaffold",
547
+ description:
548
+ 'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong - this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns the file to create + the route-module contract (loader/action/meta/default) + a stub (ready-to-write for react/preact/solid; path+contract for vue/svelte/vanilla - use nifra_example for those bodies).',
549
+ inputSchema: {
550
+ type: "object",
551
+ properties: {
552
+ path: {
553
+ type: "string",
554
+ description: 'The URL path for the new page route, e.g. "/users/:id" or "/blog/*slug".',
555
+ },
556
+ write: {
557
+ type: "boolean",
558
+ description:
559
+ "When true, create the file if a verified ready-to-write stub exists. Refuses overwrite.",
560
+ },
561
+ variant: {
562
+ type: "string",
563
+ enum: ["default", "stateful"],
564
+ description:
565
+ 'Stub flavour. "default" is the plain page stub. "stateful" (vanilla only) emits the golden nano pattern - explicit reactivity (signal + computed(fn,[deps]) + keyed bindList + collected cleanups) whose three mistakes NF-C021/C022/C023 catch statically. Use it for a small app with local state (a list a human edits) instead of reaching for a framework.',
566
+ },
567
+ },
568
+ required: ["path"],
569
+ additionalProperties: false,
570
+ },
571
+ handler: async (args) => {
572
+ const { path, write, variant } = args as {
573
+ path?: string
574
+ write?: boolean
575
+ variant?: "default" | "stateful"
576
+ }
577
+ if (typeof path !== "string" || path.length === 0) return "scaffold: `path` is required."
578
+ const flavour = variant === "stateful" ? "stateful" : "default"
579
+ const { frameworkFromClientModule, renderScaffold, writeScaffoldRoute } = await import(
580
+ "./scaffold.ts"
581
+ )
582
+ const app = await loadAppCached()
583
+ const framework = frameworkFromClientModule(app.framework.clientModule)
584
+ if (write !== true) return renderScaffold(path, framework, flavour)
585
+ const result = await writeScaffoldRoute(cwd, path, framework, flavour)
586
+ const status = result.written
587
+ ? `Written: \`${result.file}\``
588
+ : `Not written: ${result.reason ?? "no write performed"}`
589
+ return `${status}\n\n${renderScaffold(path, framework, flavour)}`
590
+ },
591
+ },
592
+ {
593
+ name: "nifra_hydrate",
594
+ description: "Run the hydration assurance gate and return stable NF-H diagnostics.",
595
+ inputSchema: {
596
+ type: "object",
597
+ properties: {
598
+ dir: { type: "string", description: "Project subdirectory to inspect." },
599
+ interact: { type: "boolean", description: "Run declared hydration probes." },
600
+ },
601
+ additionalProperties: false,
602
+ },
603
+ handler: async (args) => {
604
+ const opts = args as { dir?: string; interact?: boolean }
605
+ const target = resolveProjectDir(cwd, opts.dir)
606
+ if (target === null) return dirError(opts.dir)
607
+ const { runHydrationAssurance } = await import("./assure-hydration.ts")
608
+ return JSON.stringify(
609
+ await runHydrationAssurance(target, { interact: opts.interact === true }),
610
+ null,
611
+ 2,
612
+ )
613
+ },
614
+ },
615
+ ]
616
+ return [...catalogTools, ...legacyTools]
617
+ }
618
+
619
+ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
620
+ return fileURLToPath(new URL(import.meta.url)).replace(/mcp-exec\.(ts|js)$/, `${name}.$1`)
621
+ }
622
+
623
+ /** Spawn `bun <child> <cwd>`, pipe `input` to its stdin, return its stdout (or a stderr-backed error). */
624
+ /**
625
+ * How long a child gets before it is killed.
626
+ *
627
+ * A child loads the app, which runs its module side effects - a database pool, a Redis client, a
628
+ * metrics interval. Any of those keeps the child's event loop alive after the work is done, and the
629
+ * parent is sitting in `await proc.exited`. The children now exit explicitly, so this is the backstop
630
+ * rather than the fix: without it a single wedged child blocks an agent indefinitely, and the caller
631
+ * has no way to tell "still working" from "never returning".
632
+ *
633
+ * Generous enough for a cold start that compiles route modules, short enough that a hang is reported
634
+ * within one attention span rather than after minutes of polling.
635
+ */
636
+ export async function spawnChild(
637
+ child: "mcp-run" | "mcp-render" | "mcp-ws",
638
+ cwd: string,
639
+ input: unknown,
640
+ label: string,
641
+ signal?: AbortSignal,
642
+ ): Promise<string> {
643
+ if (signal?.aborted) return `${label} cancelled before it started.`
644
+ const proc = Bun.spawn(["bun", childPath(child), cwd], {
645
+ stdin: "pipe",
646
+ stdout: "pipe",
647
+ stderr: "pipe",
648
+ })
649
+ let killed = false
650
+ const kill = (): void => {
651
+ if (killed) return
652
+ killed = true
653
+ proc.kill()
654
+ }
655
+ const abort = (): void => kill()
656
+ signal?.addEventListener("abort", abort, { once: true })
657
+ let timedOut = false
658
+ const timer = setTimeout(() => {
659
+ timedOut = true
660
+ kill()
661
+ }, CHILD_TIMEOUT_MS)
662
+ let outputExceeded = false
663
+ try {
664
+ proc.stdin.write(JSON.stringify(input))
665
+ await proc.stdin.end()
666
+ const [out, err] = await Promise.all([
667
+ readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
668
+ outputExceeded = true
669
+ kill()
670
+ }),
671
+ readBoundedStream(proc.stderr, CHILD_OUTPUT_MAX_BYTES, () => {
672
+ outputExceeded = true
673
+ kill()
674
+ }),
675
+ ])
676
+ await proc.exited
677
+ if (signal?.aborted) {
678
+ const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
679
+ return `${label} cancelled${reason}.`
680
+ }
681
+ if (outputExceeded) {
682
+ return `${label} output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes and was terminated.`
683
+ }
684
+ // A killed child may still have flushed a complete result before the timer fired; prefer real
685
+ // output over the timeout message so a slow-but-successful render is not reported as a failure.
686
+ const text = out.text.trim()
687
+ if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
688
+ return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
689
+ } finally {
690
+ clearTimeout(timer)
691
+ signal?.removeEventListener("abort", abort)
692
+ }
693
+ }
694
+
695
+ const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
696
+ const WARM_RUN_IGNORED =
697
+ /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
698
+ const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
699
+
700
+ async function warmRunFingerprint(cwd: string): Promise<string> {
701
+ const parts: string[] = []
702
+ for await (const rawRel of WARM_RUN_GLOB.scan({ cwd, dot: false })) {
703
+ const rel = rawRel.replaceAll("\\", "/")
704
+ if (WARM_RUN_IGNORED.test(rel)) continue
705
+ try {
706
+ const s = await stat(resolve(cwd, rel))
707
+ if (s.isFile()) parts.push(`${rel}:${s.mtimeMs}:${s.size}`)
708
+ } catch {
709
+ // A file can disappear while an agent is editing. The next call will rescan the settled tree.
710
+ }
711
+ }
712
+ for (const rel of WARM_RUN_EXTRA_FILES) {
713
+ parts.push(`${rel}:${await fileFingerprint(resolve(cwd, rel))}`)
714
+ }
715
+ return cacheToken(parts.sort().join("|"))
716
+ }
717
+
718
+ function boundedAppend(current: string, next: string, max = 12_000): string {
719
+ const combined = `${current}${next}`
720
+ return combined.length <= max ? combined : combined.slice(combined.length - max)
721
+ }
722
+
723
+ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
724
+ readonly stdin: { write(input: string | Uint8Array): unknown }
725
+ readonly stdout: ReadableStream<Uint8Array>
726
+ readonly stderr: ReadableStream<Uint8Array>
727
+ }
728
+
729
+ /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
730
+ * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
731
+ * powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
732
+ * cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
733
+ * replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
734
+ * concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
735
+ export class WarmWorker {
736
+ private readonly proc: PipeSubprocess
737
+ private readonly pending = new Map<
738
+ number,
739
+ {
740
+ readonly resolve: (text: string) => void
741
+ readonly reject: (err: Error) => void
742
+ readonly cleanup: () => void
743
+ }
744
+ >()
745
+ private stdoutBuffer = ""
746
+ private stderrBuffer = ""
747
+ private nextId = 0
748
+ private closed = false
749
+
750
+ constructor(
751
+ child: "mcp-run" | "mcp-render",
752
+ cwd: string,
753
+ readonly fingerprint: string,
754
+ private readonly label: string,
755
+ ) {
756
+ this.proc = Bun.spawn(["bun", childPath(child), cwd, "--worker"], {
757
+ stdin: "pipe",
758
+ stdout: "pipe",
759
+ stderr: "pipe",
760
+ }) as PipeSubprocess
761
+ void this.readStdout()
762
+ void this.readStderr()
763
+ void this.proc.exited.then((code) => {
764
+ this.closed = true
765
+ const err = new Error(
766
+ `warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
767
+ )
768
+ for (const pending of this.pending.values()) {
769
+ pending.cleanup()
770
+ pending.reject(err)
771
+ }
772
+ this.pending.clear()
773
+ })
774
+ }
775
+
776
+ stop(): void {
777
+ if (!this.closed) this.proc.kill()
778
+ }
779
+
780
+ async request(input: unknown, signal?: AbortSignal): Promise<string> {
781
+ if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
782
+ if (signal?.aborted) return `${this.label} cancelled before it started.`
783
+ const id = ++this.nextId
784
+ return new Promise((resolve, reject) => {
785
+ const abort = (): void => {
786
+ // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
787
+ // across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
788
+ // process here would reject every OTHER in-flight request via the `exited` handler and force a
789
+ // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
790
+ this.pending.delete(id)
791
+ clearTimeout(timer)
792
+ const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
793
+ resolve(`${this.label} cancelled${reason}.`)
794
+ }
795
+ // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
796
+ // promise unsettled forever. Drop the id and replace the worker - unlike a per-request cancel,
797
+ // a timeout means the worker itself is suspect, so the next call should start from a fresh one.
798
+ const timer = setTimeout(() => {
799
+ if (!this.pending.delete(id)) return
800
+ signal?.removeEventListener("abort", abort)
801
+ this.stop()
802
+ resolve(timeoutMessage(this.label, CHILD_TIMEOUT_MS))
803
+ }, CHILD_TIMEOUT_MS)
804
+ const cleanup = (): void => {
805
+ clearTimeout(timer)
806
+ signal?.removeEventListener("abort", abort)
807
+ }
808
+ signal?.addEventListener("abort", abort, { once: true })
809
+ this.pending.set(id, { resolve, reject, cleanup })
810
+ try {
811
+ this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`)
812
+ } catch (err) {
813
+ this.pending.delete(id)
814
+ cleanup()
815
+ reject(err instanceof Error ? err : new Error(String(err)))
816
+ }
817
+ })
818
+ }
819
+
820
+ private async readStdout(): Promise<void> {
821
+ const decoder = new TextDecoder()
822
+ const reader = this.proc.stdout.getReader()
823
+ for (;;) {
824
+ const { done, value } = await reader.read()
825
+ if (done) break
826
+ if (value !== undefined && value.byteLength > CHILD_OUTPUT_MAX_BYTES) {
827
+ this.stderrBuffer = boundedAppend(
828
+ this.stderrBuffer,
829
+ `warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
830
+ )
831
+ this.stop()
832
+ return
833
+ }
834
+ this.stdoutBuffer += decoder.decode(value, { stream: true })
835
+ if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
836
+ this.stderrBuffer = boundedAppend(
837
+ this.stderrBuffer,
838
+ `warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
839
+ )
840
+ this.stop()
841
+ return
842
+ }
843
+ let nl = this.stdoutBuffer.indexOf("\n")
844
+ while (nl !== -1) {
845
+ const line = this.stdoutBuffer.slice(0, nl).trim()
846
+ this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
847
+ nl = this.stdoutBuffer.indexOf("\n")
848
+ if (line !== "") this.handleLine(line)
849
+ }
850
+ }
851
+ }
852
+
853
+ private async readStderr(): Promise<void> {
854
+ const decoder = new TextDecoder()
855
+ const reader = this.proc.stderr.getReader()
856
+ for (;;) {
857
+ const { done, value } = await reader.read()
858
+ if (done) break
859
+ this.stderrBuffer = boundedAppend(this.stderrBuffer, decoder.decode(value, { stream: true }))
860
+ }
861
+ }
862
+
863
+ private handleLine(line: string): void {
864
+ let message: { id?: unknown; output?: unknown }
865
+ try {
866
+ message = JSON.parse(line) as { id?: unknown; output?: unknown }
867
+ } catch {
868
+ this.stderrBuffer = boundedAppend(
869
+ this.stderrBuffer,
870
+ `invalid warm ${this.label} worker line: ${line}\n`,
871
+ )
872
+ return
873
+ }
874
+ if (typeof message.id !== "number") return
875
+ const pending = this.pending.get(message.id)
876
+ if (pending === undefined) return
877
+ this.pending.delete(message.id)
878
+ pending.cleanup()
879
+ pending.resolve(JSON.stringify(message.output, null, 2))
880
+ }
881
+ }
882
+
883
+ /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
884
+ * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
885
+ * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
886
+ * message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
887
+ export function createWarmHandler(
888
+ child: "mcp-run" | "mcp-render",
889
+ cwd: string,
890
+ label: string,
891
+ ): (input: unknown, signal?: AbortSignal) => Promise<string> {
892
+ let worker: WarmWorker | undefined
893
+ return async (input, signal) => {
894
+ const fingerprint = await warmRunFingerprint(cwd)
895
+ if (worker === undefined || worker.fingerprint !== fingerprint) {
896
+ worker?.stop()
897
+ worker = new WarmWorker(child, cwd, fingerprint, label)
898
+ }
899
+ try {
900
+ return await worker.request(input, signal)
901
+ } catch {
902
+ worker.stop()
903
+ worker = undefined
904
+ if (signal?.aborted) {
905
+ const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
906
+ return `${label} cancelled${reason}.`
907
+ }
908
+ return spawnChild(child, cwd, input, label, signal)
909
+ }
910
+ }
911
+ }
912
+
913
+ /** The `nifra_run` handler: run requests through the project's CURRENT backend, return structured results. */
914
+ export async function runHandler(
915
+ cwd: string,
916
+ args: Record<string, unknown>,
917
+ context: McpToolContext,
918
+ warmRun: (input: unknown, signal?: AbortSignal) => Promise<string>,
919
+ ): Promise<string> {
920
+ const requests = Array.isArray(args.requests) ? args.requests : []
921
+ if (requests.length === 0) {
922
+ return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
923
+ }
924
+ const input = { requests, entry: args.entry }
925
+ if ((args as { warm?: boolean }).warm === true) {
926
+ context.reportProgress(0.2, 1)
927
+ return warmRun(input, context.signal)
928
+ }
929
+ return spawnChild("mcp-run", cwd, input, "run", context.signal)
930
+ }
931
+
932
+ /** The `nifra_render` handler: SSR page routes through the project's CURRENT web app, return the HTML.
933
+ * By default a fresh subprocess loads the current code each call; `warm:true` reuses a hot worker (like
934
+ * `nifra_run`) that auto-restarts when a source file changes. */
935
+ export async function renderHandler(
936
+ cwd: string,
937
+ args: Record<string, unknown>,
938
+ context: McpToolContext,
939
+ warmRender: (input: unknown, signal?: AbortSignal) => Promise<string>,
940
+ ): Promise<string> {
941
+ const requests = Array.isArray(args.requests) ? args.requests : []
942
+ if (requests.length === 0) {
943
+ return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
944
+ }
945
+ const input = { requests }
946
+ if ((args as { warm?: boolean }).warm === true) {
947
+ context.reportProgress(0.2, 1)
948
+ return warmRender(input, context.signal)
949
+ }
950
+ return spawnChild("mcp-render", cwd, input, "render", context.signal)
951
+ }
952
+
953
+ /** The `nifra_ws` handler: verify WebSocket routes through a fresh app subprocess. */
954
+ export async function wsHandler(
955
+ cwd: string,
956
+ args: Record<string, unknown>,
957
+ context: McpToolContext,
958
+ ): Promise<string> {
959
+ return spawnChild("mcp-ws", cwd, args, "websocket", context.signal)
960
+ }
961
+ type LoadAppForCache = (
962
+ cwd: string,
963
+ outDirName?: string,
964
+ options?: LoadAppOptions,
965
+ ) => Promise<LoadedApp>
966
+
967
+ export interface CachedAppLoaderOptions {
968
+ readonly loadApp?: LoadAppForCache
969
+ readonly fingerprint?: (cwd: string) => Promise<string>
970
+ }
971
+
972
+ const APP_FINGERPRINT_FILES = ["nifra.config.ts", "framework.ts", "backend.ts"] as const
973
+
974
+ function cacheToken(input: string): string {
975
+ let hash = 2166136261
976
+ for (let i = 0; i < input.length; i++) {
977
+ hash ^= input.charCodeAt(i)
978
+ hash = Math.imul(hash, 16777619)
979
+ }
980
+ return `mcp=${(hash >>> 0).toString(36)}`
981
+ }
982
+
983
+ async function fileFingerprint(path: string): Promise<string> {
984
+ try {
985
+ const s = await stat(path)
986
+ return `${s.mtimeMs}:${s.size}`
987
+ } catch (err) {
988
+ if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
989
+ return "missing"
990
+ }
991
+ throw err
992
+ }
993
+ }
994
+
995
+ async function appFingerprint(cwd: string): Promise<string> {
996
+ return (
997
+ await Promise.all(
998
+ APP_FINGERPRINT_FILES.map(
999
+ async (file) => `${file}:${await fileFingerprint(resolve(cwd, file))}`,
1000
+ ),
1001
+ )
1002
+ ).join("|")
1003
+ }
1004
+
1005
+ /** Cache LoadedApp inside one MCP server process, invalidating when config/backend mtimes change. */
1006
+ export function createCachedAppLoader(
1007
+ cwd: string,
1008
+ options: CachedAppLoaderOptions = {},
1009
+ ): (outDirName?: string) => Promise<LoadedApp> {
1010
+ const loadAppCached =
1011
+ options.loadApp ??
1012
+ (async (root: string, outDirName?: string, loadOptions?: LoadAppOptions) => {
1013
+ const mod = await import("./load.ts")
1014
+ return mod.loadApp(root, outDirName, loadOptions)
1015
+ })
1016
+ const fingerprint = options.fingerprint ?? appFingerprint
1017
+ let cached:
1018
+ | {
1019
+ readonly outDirName: string
1020
+ readonly fingerprint: string
1021
+ readonly app: LoadedApp
1022
+ }
1023
+ | undefined
1024
+
1025
+ return async (outDirName = "dist") => {
1026
+ const nextFingerprint = await fingerprint(cwd)
1027
+ if (
1028
+ cached !== undefined &&
1029
+ cached.outDirName === outDirName &&
1030
+ cached.fingerprint === nextFingerprint
1031
+ ) {
1032
+ return cached.app
1033
+ }
1034
+ const app = await loadAppCached(cwd, outDirName, {
1035
+ importQuery: cacheToken(nextFingerprint),
1036
+ })
1037
+ cached = { outDirName, fingerprint: nextFingerprint, app }
1038
+ return app
1039
+ }
1040
+ }