@nifrajs/cli 2.2.0 → 2.4.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 (135) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +61 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +336 -87
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +30 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +148 -39
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +3 -3
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +12 -8
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/learn.d.ts +32 -0
  37. package/dist/learn.d.ts.map +1 -0
  38. package/dist/learn.js +127 -0
  39. package/dist/learn.js.map +1 -0
  40. package/dist/levels-tool.d.ts +8 -8
  41. package/dist/levels-tool.d.ts.map +1 -1
  42. package/dist/levels-tool.js +33 -30
  43. package/dist/levels-tool.js.map +1 -1
  44. package/dist/load.d.ts +6 -6
  45. package/dist/load.d.ts.map +1 -1
  46. package/dist/load.js +15 -7
  47. package/dist/load.js.map +1 -1
  48. package/dist/manifest-tool.d.ts +1 -1
  49. package/dist/manifest-tool.d.ts.map +1 -1
  50. package/dist/manifest-tool.js +4 -2
  51. package/dist/manifest-tool.js.map +1 -1
  52. package/dist/mcp-app-tool.d.ts +14 -0
  53. package/dist/mcp-app-tool.d.ts.map +1 -0
  54. package/dist/mcp-app-tool.js +80 -0
  55. package/dist/mcp-app-tool.js.map +1 -0
  56. package/dist/mcp-docs-tools.d.ts +1 -1
  57. package/dist/mcp-docs-tools.d.ts.map +1 -1
  58. package/dist/mcp-docs-tools.js +25 -9
  59. package/dist/mcp-docs-tools.js.map +1 -1
  60. package/dist/mcp-http.d.ts +14 -10
  61. package/dist/mcp-http.d.ts.map +1 -1
  62. package/dist/mcp-http.js +30 -16
  63. package/dist/mcp-http.js.map +1 -1
  64. package/dist/mcp-protocol.d.ts +2 -2
  65. package/dist/mcp-protocol.js +2 -2
  66. package/dist/mcp-render.d.ts +3 -3
  67. package/dist/mcp-render.js +6 -6
  68. package/dist/mcp-run.d.ts +2 -2
  69. package/dist/mcp-run.js +2 -2
  70. package/dist/mcp.d.ts +28 -14
  71. package/dist/mcp.d.ts.map +1 -1
  72. package/dist/mcp.js +263 -45
  73. package/dist/mcp.js.map +1 -1
  74. package/dist/openapi-tool.d.ts +1 -1
  75. package/dist/openapi-tool.js +2 -2
  76. package/dist/pipeline-guard.d.ts +1 -1
  77. package/dist/pipeline-guard.js +1 -1
  78. package/dist/port.d.ts +9 -9
  79. package/dist/port.js +27 -27
  80. package/dist/recipes/1.8.0.d.ts +2 -2
  81. package/dist/recipes/1.8.0.js +5 -5
  82. package/dist/recipes/index.d.ts +2 -2
  83. package/dist/recipes/index.js +1 -1
  84. package/dist/scaffold.d.ts +3 -3
  85. package/dist/scaffold.js +8 -8
  86. package/dist/sync-routes.d.ts +28 -0
  87. package/dist/sync-routes.d.ts.map +1 -0
  88. package/dist/sync-routes.js +55 -0
  89. package/dist/sync-routes.js.map +1 -0
  90. package/dist/types-search.d.ts +5 -5
  91. package/dist/types-search.js +7 -7
  92. package/dist/upgrade.d.ts +20 -9
  93. package/dist/upgrade.d.ts.map +1 -1
  94. package/dist/upgrade.js +117 -19
  95. package/dist/upgrade.js.map +1 -1
  96. package/dist/verification.d.ts +51 -0
  97. package/dist/verification.d.ts.map +1 -0
  98. package/dist/verification.js +80 -0
  99. package/dist/verification.js.map +1 -0
  100. package/docs/examples.json +174 -90
  101. package/docs/llms-full.txt +1771 -748
  102. package/docs/types.json +2027 -3089
  103. package/package.json +22 -11
  104. package/src/assure.ts +17 -18
  105. package/src/capabilities-tool.ts +82 -16
  106. package/src/check.ts +410 -104
  107. package/src/cli.ts +161 -41
  108. package/src/diff-tool.ts +8 -8
  109. package/src/docs-search.ts +6 -6
  110. package/src/doctor.ts +18 -10
  111. package/src/examples.ts +7 -7
  112. package/src/init-agents.ts +17 -17
  113. package/src/internal/typescript-import.ts +33 -0
  114. package/src/introspect.ts +28 -28
  115. package/src/learn.ts +152 -0
  116. package/src/levels-tool.ts +37 -31
  117. package/src/load.ts +21 -12
  118. package/src/manifest-tool.ts +4 -2
  119. package/src/mcp-app-tool.ts +86 -0
  120. package/src/mcp-docs-tools.ts +26 -9
  121. package/src/mcp-http.ts +31 -18
  122. package/src/mcp-protocol.ts +2 -2
  123. package/src/mcp-render.ts +6 -6
  124. package/src/mcp-run.ts +2 -2
  125. package/src/mcp.ts +319 -45
  126. package/src/openapi-tool.ts +2 -2
  127. package/src/pipeline-guard.ts +1 -1
  128. package/src/port.ts +31 -31
  129. package/src/recipes/1.8.0.ts +5 -5
  130. package/src/recipes/index.ts +2 -2
  131. package/src/scaffold.ts +8 -8
  132. package/src/sync-routes.ts +69 -0
  133. package/src/types-search.ts +8 -8
  134. package/src/upgrade.ts +137 -19
  135. package/src/verification.ts +123 -0
package/src/mcp.ts CHANGED
@@ -1,27 +1,33 @@
1
1
  /**
2
- * `nifra mcp` — a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
2
+ * `nifra mcp` - a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
3
3
  * act on a nifra project, not just read about it:
4
4
  *
5
- * - `nifra_context` — this project's API routes + page routes + conventions (see {@link describeProject}).
6
- * - `nifra_run` — run HTTP requests through the project's backend and return structured results
5
+ * - `nifra_context` - this project's API routes + page routes + conventions (see {@link describeProject}).
6
+ * - `nifra_run` - run HTTP requests through the project's backend and return structured results
7
7
  * (status, body, errors). The write → run → see-the-failure → fix loop, powered by `@nifrajs/runner`.
8
8
  * Each call runs the backend in a FRESH subprocess, so it reflects the agent's latest edits.
9
- * - `nifra_render` — SSR a page route through the project's web app; returns the rendered HTML (the
9
+ * - `nifra_render` - SSR a page route through the project's web app; returns the rendered HTML (the
10
10
  * page half of `nifra_run`). Fresh subprocess per call; no build needed.
11
- * - `nifra_ws` — verify a WebSocket route with a real Bun WebSocket round-trip.
12
- * - `nifra_docs` — keyword-search the framework docs; returns only the matching sections.
13
- * - `nifra_example` — a verified, copy-pasteable snippet for a task (typechecked against the live API,
11
+ * - `nifra_ws` - verify a WebSocket route with a real Bun WebSocket round-trip.
12
+ * - `nifra_docs` - keyword-search the framework docs; returns only the matching sections.
13
+ * - `nifra_example` - a verified, copy-pasteable snippet for a task (typechecked against the live API,
14
14
  * so it can't hallucinate a drifted nifra API).
15
- * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
- * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
- * - `nifra_assure` — route classification + enforcement-evidence gate.
15
+ * - `nifra_learn` - the guided, ordered path to build a nifra app end to end (create → deploy), each
16
+ * step naming the tool that emits the correct artifact and how to verify it.
17
+ * - `nifra_scaffold`- map a URL path to the correct `routes/` file + a contract-correct page stub.
18
+ * - `nifra_check` - the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
19
+ * - `nifra_assure` - route classification + enforcement-evidence gate.
18
20
  * - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
19
21
  * project actually proves, and why each level it misses does not hold.
20
- * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
22
+ * - `nifra_doctor` - package.json dependency drift detector, with safe local-version auto-fix.
23
+ * - `nifra_explain` - resolve an error (pasted, or the dev server's last) into a structured
24
+ * diagnostic: stable code, a codeframe in the user's source, and the recognised cause + fix.
25
+ * - `nifra_inspect` - read the running dev server's recent request traces (method/path/status/
26
+ * duration/ISR) from the DevTools plugin: what your requests ACTUALLY did, not a guess.
21
27
  *
22
28
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
23
29
  * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
24
- * standard MCP progress notifications and request cancellation — no SDK dependency, the same
30
+ * standard MCP progress notifications and request cancellation - no SDK dependency, the same
25
31
  * minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
26
32
  * module is the I/O shell (stdin loop, tool wiring, the run subprocess).
27
33
  */
@@ -75,6 +81,67 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
75
81
  */
76
82
  const CHILD_TIMEOUT_MS = 30_000
77
83
 
84
+ /** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
85
+ * buffer an unrelated loopback service just because a caller supplied its port. */
86
+ export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
87
+ export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
88
+
89
+ /** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
90
+ export function validateLocalPort(port: unknown): number | undefined {
91
+ return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
92
+ ? port
93
+ : undefined
94
+ }
95
+
96
+ /** Read a dev-tool response without allowing an untrusted localhost service to allocate unbounded memory. */
97
+ export async function readBoundedResponse(
98
+ response: Response,
99
+ maxBytes = LOCAL_TOOL_MAX_RESPONSE_BYTES,
100
+ ): Promise<string> {
101
+ const declared = response.headers.get("content-length")
102
+ if (declared !== null) {
103
+ const length = Number(declared)
104
+ if (Number.isFinite(length) && length > maxBytes)
105
+ throw new Error("response exceeded the size limit")
106
+ }
107
+ if (response.body === null) return ""
108
+ const reader = response.body.getReader()
109
+ const chunks: Uint8Array[] = []
110
+ let total = 0
111
+ try {
112
+ for (;;) {
113
+ const { done, value } = await reader.read()
114
+ if (done) break
115
+ if (value === undefined) continue
116
+ total += value.byteLength
117
+ if (total > maxBytes) {
118
+ await reader.cancel()
119
+ throw new Error("response exceeded the size limit")
120
+ }
121
+ chunks.push(value)
122
+ }
123
+ } finally {
124
+ reader.releaseLock()
125
+ }
126
+ const bytes = new Uint8Array(total)
127
+ let offset = 0
128
+ for (const chunk of chunks) {
129
+ bytes.set(chunk, offset)
130
+ offset += chunk.byteLength
131
+ }
132
+ return new TextDecoder().decode(bytes)
133
+ }
134
+
135
+ const notNifraResponse = (service: string): string =>
136
+ JSON.stringify(
137
+ {
138
+ code: "NIFRA_NOT_DEV_SERVER",
139
+ message: `The service on this port is not a Nifra ${service} endpoint.`,
140
+ },
141
+ null,
142
+ 2,
143
+ )
144
+
78
145
  const timeoutMessage = (label: string, ms: number): string =>
79
146
  `${label} timed out after ${ms / 1000}s and was terminated.\n` +
80
147
  `The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
@@ -159,7 +226,7 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
159
226
 
160
227
  /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
161
228
  * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
162
- * powers both `nifra_run warm` and `nifra_render warm` — `child` selects which engine, `label` shapes the
229
+ * powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
163
230
  * cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
164
231
  * replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
165
232
  * concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
@@ -217,7 +284,7 @@ export class WarmWorker {
217
284
  // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
218
285
  // across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
219
286
  // process here would reject every OTHER in-flight request via the `exited` handler and force a
220
- // cold rebuild. Leave it hot — `createWarmHandler` already replaces it on file change.
287
+ // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
221
288
  this.pending.delete(id)
222
289
  clearTimeout(timer)
223
290
  const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
@@ -297,7 +364,7 @@ export class WarmWorker {
297
364
 
298
365
  /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
299
366
  * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
300
- * `nifra_render` (`child: "mcp-render"`, `label: "render"`) — the only differences are the engine and the
367
+ * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
301
368
  * message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
302
369
  function createWarmHandler(
303
370
  child: "mcp-run" | "mcp-render",
@@ -616,7 +683,7 @@ export function projectFeatures(
616
683
  }
617
684
 
618
685
  /**
619
- * Resolve an optional `dir` tool argument — a subdirectory of the project root the caller wants to scope a
686
+ * Resolve an optional `dir` tool argument - a subdirectory of the project root the caller wants to scope a
620
687
  * check/test to (e.g. `nifra check` on `app/` when the MCP server's root is a monorepo). Returns the
621
688
  * absolute target, or `null` if `dir` escapes the root (a path-traversal guard: no `..` out, no absolute
622
689
  * path elsewhere). `undefined`/empty → the root itself.
@@ -630,7 +697,7 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
630
697
  /** Consistent error string for a `dir` that escapes the project root. */
631
698
  function dirError(dir: string | undefined): string {
632
699
  return JSON.stringify(
633
- { ok: false, error: `dir must be a subdirectory of the project root — "${dir}" escapes it.` },
700
+ { ok: false, error: `dir must be a subdirectory of the project root - "${dir}" escapes it.` },
634
701
  null,
635
702
  2,
636
703
  )
@@ -647,7 +714,7 @@ export function projectTools(
647
714
  {
648
715
  name: "nifra_context",
649
716
  description:
650
- "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer — cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
717
+ "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer - cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
651
718
  inputSchema: {
652
719
  type: "object",
653
720
  properties: {
@@ -668,10 +735,180 @@ export function projectTools(
668
735
  return describeProject(await loadAppCached(), filter)
669
736
  },
670
737
  },
738
+ {
739
+ name: "nifra_explain",
740
+ description:
741
+ "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.",
742
+ inputSchema: {
743
+ type: "object",
744
+ properties: {
745
+ error: {
746
+ type: "string",
747
+ description:
748
+ "The error message to explain (copy it from nifra_run/nifra_test output or a failing build).",
749
+ },
750
+ stack: {
751
+ type: "string",
752
+ description:
753
+ "The error's stack trace, if you have it - enables the source codeframe and the top user frame.",
754
+ },
755
+ name: {
756
+ type: "string",
757
+ description:
758
+ "The error's class name (e.g. TypeError, SchemaError), if known - sharpens classification.",
759
+ },
760
+ port: {
761
+ type: "number",
762
+ description:
763
+ "Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
764
+ },
765
+ },
766
+ additionalProperties: false,
767
+ },
768
+ handler: async (args) => {
769
+ const { error, stack, name, port } = args as {
770
+ error?: string
771
+ stack?: string
772
+ name?: string
773
+ port?: number
774
+ }
775
+ if (error !== undefined || stack !== undefined) {
776
+ const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
777
+ const e = new Error(error ?? "")
778
+ if (name !== undefined) e.name = name
779
+ if (stack !== undefined) e.stack = stack
780
+ return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
781
+ }
782
+ if (port !== undefined) {
783
+ const validPort = validateLocalPort(port)
784
+ if (validPort === undefined) {
785
+ return JSON.stringify(
786
+ { code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
787
+ null,
788
+ 2,
789
+ )
790
+ }
791
+ const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
792
+ try {
793
+ const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
794
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
795
+ })
796
+ if (res.headers.get("x-nifra-diagnostic") !== "true")
797
+ return notNifraResponse("diagnostic")
798
+ if (!res.ok) {
799
+ return JSON.stringify(
800
+ {
801
+ code: "NIFRA_NONE",
802
+ message: `dev server at :${validPort} returned ${res.status}`,
803
+ },
804
+ null,
805
+ 2,
806
+ )
807
+ }
808
+ return await readBoundedResponse(res)
809
+ } catch (cause) {
810
+ return JSON.stringify(
811
+ {
812
+ code: "NIFRA_NONE",
813
+ message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
814
+ },
815
+ null,
816
+ 2,
817
+ )
818
+ }
819
+ }
820
+ return JSON.stringify(
821
+ {
822
+ code: "NIFRA_NONE",
823
+ message:
824
+ "Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
825
+ },
826
+ null,
827
+ 2,
828
+ )
829
+ },
830
+ },
831
+ {
832
+ name: "nifra_inspect",
833
+ description:
834
+ "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).",
835
+ inputSchema: {
836
+ type: "object",
837
+ properties: {
838
+ port: { type: "number", description: "The running dev server's port." },
839
+ path: { type: "string", description: "Only traces whose path starts with this prefix." },
840
+ limit: { type: "number", description: "Return only the most recent N traces." },
841
+ },
842
+ required: ["port"],
843
+ additionalProperties: false,
844
+ },
845
+ handler: async (args) => {
846
+ const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
847
+ if (port === undefined) {
848
+ return JSON.stringify(
849
+ {
850
+ events: [],
851
+ note: "Pass `port` - the running dev server whose request traces to read.",
852
+ },
853
+ null,
854
+ 2,
855
+ )
856
+ }
857
+ try {
858
+ const validPort = validateLocalPort(port)
859
+ if (validPort === undefined) {
860
+ return JSON.stringify(
861
+ {
862
+ events: [],
863
+ code: "NIFRA_INVALID_PORT",
864
+ note: "port must be an integer from 1 to 65535.",
865
+ },
866
+ null,
867
+ 2,
868
+ )
869
+ }
870
+ const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
871
+ if (path !== undefined) url.searchParams.set("path", path)
872
+ if (limit !== undefined) url.searchParams.set("limit", String(limit))
873
+ const res = await fetch(url, {
874
+ signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
875
+ })
876
+ if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
877
+ if (res.status === 404) {
878
+ return JSON.stringify(
879
+ {
880
+ events: [],
881
+ note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
882
+ },
883
+ null,
884
+ 2,
885
+ )
886
+ }
887
+ if (!res.ok) {
888
+ return JSON.stringify(
889
+ { events: [], note: `DevTools state returned ${res.status}.` },
890
+ null,
891
+ 2,
892
+ )
893
+ }
894
+ return await readBoundedResponse(res)
895
+ } catch (cause) {
896
+ const validPort = validateLocalPort(port)
897
+ return JSON.stringify(
898
+ {
899
+ events: [],
900
+ note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
901
+ },
902
+ null,
903
+ 2,
904
+ )
905
+ }
906
+ },
907
+ },
671
908
  {
672
909
  name: "nifra_routes",
673
910
  description:
674
- "List this project's API routes as STRUCTURED JSON — each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
911
+ "List this project's API routes as STRUCTURED JSON - each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
675
912
  inputSchema: {
676
913
  type: "object",
677
914
  properties: {
@@ -851,7 +1088,7 @@ export function projectTools(
851
1088
  {
852
1089
  name: "nifra_scaffold",
853
1090
  description:
854
- '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).',
1091
+ '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).',
855
1092
  inputSchema: {
856
1093
  type: "object",
857
1094
  properties: {
@@ -887,7 +1124,7 @@ export function projectTools(
887
1124
  {
888
1125
  name: "nifra_check",
889
1126
  description:
890
- 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done — fix every diagnostic before finishing.',
1127
+ 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
891
1128
  inputSchema: {
892
1129
  type: "object",
893
1130
  properties: {
@@ -1086,7 +1323,7 @@ export function projectTools(
1086
1323
  {
1087
1324
  name: "nifra_doctor",
1088
1325
  description:
1089
- "Check this project for packages imported in source but NOT declared in package.json — the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1326
+ "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1090
1327
  inputSchema: {
1091
1328
  type: "object",
1092
1329
  properties: {
@@ -1259,6 +1496,54 @@ function namespaceForApp(
1259
1496
  }
1260
1497
  }
1261
1498
 
1499
+ /**
1500
+ * Merge the backend-derived resources/prompts into `base` - and keep a project that cannot be loaded
1501
+ * from deciding whether the server starts at all.
1502
+ *
1503
+ * Resources and prompts are OPTIONAL capabilities extracted from the app's backend, so this load is a
1504
+ * best-effort enrichment. It was awaited unguarded, which made it a hard boot requirement: `loadApp`
1505
+ * needs a web config (`adapter` + `clientModule`) and a `routes/` directory, and a backend-only
1506
+ * project - the shape `create-nifra`'s DEFAULT template produces - has neither. On those projects the
1507
+ * throw escaped before the JSON-RPC loop began, so the server wrote nothing and exited. `nifra
1508
+ * init-agents` would happily register an `.mcp.json` pointing at a server that could never start, and
1509
+ * every tool went with it, including the twelve that never touch the app (docs, examples, types,
1510
+ * check, doctor, levels, test).
1511
+ *
1512
+ * Degrading costs only the backend resources/prompts. Tools that genuinely need the app still fail,
1513
+ * but per call, with their own message - which is a diagnosis instead of a silence.
1514
+ */
1515
+ async function backendFeatures(
1516
+ loader: () => Promise<LoadedApp>,
1517
+ base: McpServerFeatures,
1518
+ ): Promise<McpServerFeatures> {
1519
+ const resources = [...(base.resources ?? [])]
1520
+ const prompts = [...(base.prompts ?? [])]
1521
+ try {
1522
+ const app = await loader()
1523
+ resources.push(...extractBackendResources(app.backend))
1524
+ prompts.push(...extractBackendPrompts(app.backend))
1525
+ } catch {
1526
+ // Not loadable here (no web config, or the config itself throws). The server still serves.
1527
+ }
1528
+ return { resources, prompts }
1529
+ }
1530
+
1531
+ /**
1532
+ * The MCP tools the app itself declares via `app.tool(...)`, or none when the app cannot be loaded.
1533
+ *
1534
+ * Same reasoning as {@link backendFeatures}, on the hotter path: this ran on EVERY JSON-RPC message,
1535
+ * so an unloadable project failed `initialize` itself with a `-32603` and the session never opened.
1536
+ * An app's own tools are an extension of the built-in set, so their absence must not withdraw the
1537
+ * built-ins - a backend-only project still gets docs, examples, types, check, doctor, levels and test.
1538
+ */
1539
+ async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTool[]> {
1540
+ try {
1541
+ return extractBackendTools((await loader()).backend)
1542
+ } catch {
1543
+ return []
1544
+ }
1545
+ }
1546
+
1262
1547
  /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
1263
1548
  export async function runMcpServer(cwd: string, version: string): Promise<void> {
1264
1549
  let features: McpServerFeatures
@@ -1270,24 +1555,18 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1270
1555
  const allPrompts: McpPrompt[] = []
1271
1556
  for (const { name, cwd: appCwd } of appEntries) {
1272
1557
  const loader = createCachedAppLoader(appCwd)
1273
- const app = await loader()
1274
- const base = projectFeatures(appCwd, loader)
1275
- const ns = namespaceForApp(name, [], {
1276
- resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1277
- prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1278
- })
1558
+ const ns = namespaceForApp(
1559
+ name,
1560
+ [],
1561
+ await backendFeatures(loader, projectFeatures(appCwd, loader)),
1562
+ )
1279
1563
  allResources.push(...(ns.features.resources ?? []))
1280
1564
  allPrompts.push(...(ns.features.prompts ?? []))
1281
1565
  }
1282
1566
  features = { resources: allResources, prompts: allPrompts }
1283
1567
  } else {
1284
1568
  const loadAppCached = createCachedAppLoader(cwd)
1285
- const base = projectFeatures(cwd, loadAppCached)
1286
- const app = await loadAppCached()
1287
- features = {
1288
- resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1289
- prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1290
- }
1569
+ features = await backendFeatures(loadAppCached, projectFeatures(cwd, loadAppCached))
1291
1570
  }
1292
1571
  const loadAppCached = createCachedAppLoader(cwd)
1293
1572
  const serverInfo = { name: "nifra", version }
@@ -1302,21 +1581,16 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1302
1581
  const allTools: McpTool[] = []
1303
1582
  for (const { name, cwd: appCwd } of appEntries) {
1304
1583
  const loader = createCachedAppLoader(appCwd)
1305
- const app = await loader()
1306
- const baseTools = projectTools(appCwd, loader)
1307
- const backendTools = extractBackendTools(app.backend)
1308
- const ns = namespaceForApp(name, [...baseTools, ...backendTools], {
1309
- resources: [],
1310
- prompts: [],
1311
- })
1584
+ const tools = [...projectTools(appCwd, loader), ...(await appDeclaredTools(loader))]
1585
+ const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
1312
1586
  allTools.push(...ns.tools)
1313
1587
  }
1314
1588
  activeTools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
1315
1589
  } else {
1316
- const app = await loadAppCached()
1317
- const baseTools = projectTools(cwd, loadAppCached)
1318
- const backendTools = extractBackendTools(app.backend)
1319
- activeTools = [...baseTools, ...backendTools]
1590
+ activeTools = [
1591
+ ...projectTools(cwd, loadAppCached),
1592
+ ...(await appDeclaredTools(loadAppCached)),
1593
+ ]
1320
1594
  }
1321
1595
 
1322
1596
  const response = await handleRpc(message, activeTools, serverInfo, features, {
@@ -45,7 +45,7 @@ function toYaml(value: unknown, depth = 0): string {
45
45
  * Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
46
46
  * easier for agents and API tooling to handle than a tool error.
47
47
  *
48
- * `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix —
48
+ * `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix -
49
49
  * mirroring `nifra_routes`'s `path` filter so a large backend needn't return the whole document. The keys
50
50
  * are templated (`/users/{id}`), so a prefix up to the first param (`/api/orders`) matches `/api/orders`
51
51
  * and `/api/orders/{id}` alike. `components.schemas` is left intact: it's keyed by schema name (not path),
@@ -57,7 +57,7 @@ export function renderOpenApi(app: LoadedApp, format: OpenApiFormat, pathPrefix?
57
57
  const input = hasRoutesMethod(app.backend) ? app.backend : {}
58
58
  const generated = toOpenAPI(input, { title, version: "1.0.0" })
59
59
  // Narrow to operations under the prefix, rebuilding the doc (its `paths` is readonly). `components`
60
- // stays — see the doc comment for why the schema set isn't recomputed.
60
+ // stays - see the doc comment for why the schema set isn't recomputed.
61
61
  const document =
62
62
  pathPrefix !== undefined && pathPrefix !== ""
63
63
  ? {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pipeline separation guard — one bundler owns a phase, never two.
2
+ * Pipeline separation guard - one bundler owns a phase, never two.
3
3
  *
4
4
  * nifra supports both Vite and Bun, and the config keeps them in separate slots: `vitePlugins` for
5
5
  * the Vite pipeline, `clientPlugins`/`serverPlugins` for the Bun one. Nothing enforces the split, so