@nifrajs/cli 2.11.0 → 2.12.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 (177) hide show
  1. package/README.md +6 -0
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +125 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +706 -145
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +23 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +194 -10
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/diagnostics.d.ts +23 -0
  31. package/dist/diagnostics.d.ts.map +1 -0
  32. package/dist/diagnostics.js +9 -0
  33. package/dist/diagnostics.js.map +1 -0
  34. package/dist/diff-tool.d.ts +2 -0
  35. package/dist/diff-tool.d.ts.map +1 -1
  36. package/dist/diff-tool.js +1 -1
  37. package/dist/diff-tool.js.map +1 -1
  38. package/dist/docs-search.d.ts +8 -3
  39. package/dist/docs-search.d.ts.map +1 -1
  40. package/dist/docs-search.js +36 -15
  41. package/dist/docs-search.js.map +1 -1
  42. package/dist/doctor.d.ts +28 -2
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +173 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/examples.d.ts +1 -2
  47. package/dist/examples.d.ts.map +1 -1
  48. package/dist/examples.js +37 -16
  49. package/dist/examples.js.map +1 -1
  50. package/dist/fix-recipes.d.ts +12 -0
  51. package/dist/fix-recipes.d.ts.map +1 -0
  52. package/dist/fix-recipes.js +106 -0
  53. package/dist/fix-recipes.js.map +1 -0
  54. package/dist/init-agents.d.ts.map +1 -1
  55. package/dist/init-agents.js +60 -4
  56. package/dist/init-agents.js.map +1 -1
  57. package/dist/internal/source-facts.d.ts +15 -0
  58. package/dist/internal/source-facts.d.ts.map +1 -0
  59. package/dist/internal/source-facts.js +82 -0
  60. package/dist/internal/source-facts.js.map +1 -0
  61. package/dist/internal/typescript-import.d.ts +9 -0
  62. package/dist/internal/typescript-import.d.ts.map +1 -1
  63. package/dist/internal/typescript-import.js +38 -0
  64. package/dist/internal/typescript-import.js.map +1 -1
  65. package/dist/introspect.d.ts +6 -2
  66. package/dist/introspect.d.ts.map +1 -1
  67. package/dist/introspect.js +53 -4
  68. package/dist/introspect.js.map +1 -1
  69. package/dist/load.d.ts +6 -0
  70. package/dist/load.d.ts.map +1 -1
  71. package/dist/load.js.map +1 -1
  72. package/dist/manifest-tool.d.ts.map +1 -1
  73. package/dist/manifest-tool.js +14 -9
  74. package/dist/manifest-tool.js.map +1 -1
  75. package/dist/mcp-docs-tools.d.ts.map +1 -1
  76. package/dist/mcp-docs-tools.js +3 -0
  77. package/dist/mcp-docs-tools.js.map +1 -1
  78. package/dist/mcp-http.js +1 -1
  79. package/dist/mcp-root.d.ts +83 -0
  80. package/dist/mcp-root.d.ts.map +1 -0
  81. package/dist/mcp-root.js +221 -0
  82. package/dist/mcp-root.js.map +1 -0
  83. package/dist/mcp-run.d.ts.map +1 -1
  84. package/dist/mcp-run.js +18 -4
  85. package/dist/mcp-run.js.map +1 -1
  86. package/dist/mcp.d.ts +27 -4
  87. package/dist/mcp.d.ts.map +1 -1
  88. package/dist/mcp.js +358 -25
  89. package/dist/mcp.js.map +1 -1
  90. package/dist/project-path.d.ts +3 -0
  91. package/dist/project-path.d.ts.map +1 -0
  92. package/dist/project-path.js +36 -0
  93. package/dist/project-path.js.map +1 -0
  94. package/dist/replay.d.ts +12 -0
  95. package/dist/replay.d.ts.map +1 -0
  96. package/dist/replay.js +36 -0
  97. package/dist/replay.js.map +1 -0
  98. package/dist/rules/codes.d.ts +42 -0
  99. package/dist/rules/codes.d.ts.map +1 -0
  100. package/dist/rules/codes.js +52 -0
  101. package/dist/rules/codes.js.map +1 -0
  102. package/dist/rules/comment-markers.d.ts +10 -0
  103. package/dist/rules/comment-markers.d.ts.map +1 -0
  104. package/dist/rules/comment-markers.js +24 -0
  105. package/dist/rules/comment-markers.js.map +1 -0
  106. package/dist/rules/index.d.ts +31 -0
  107. package/dist/rules/index.d.ts.map +1 -0
  108. package/dist/rules/index.js +133 -0
  109. package/dist/rules/index.js.map +1 -0
  110. package/dist/rules/legacy.d.ts +7 -0
  111. package/dist/rules/legacy.d.ts.map +1 -0
  112. package/dist/rules/legacy.js +85 -0
  113. package/dist/rules/legacy.js.map +1 -0
  114. package/dist/rules/routes.d.ts +12 -0
  115. package/dist/rules/routes.d.ts.map +1 -0
  116. package/dist/rules/routes.js +142 -0
  117. package/dist/rules/routes.js.map +1 -0
  118. package/dist/rules/security.d.ts +10 -0
  119. package/dist/rules/security.d.ts.map +1 -0
  120. package/dist/rules/security.js +471 -0
  121. package/dist/rules/security.js.map +1 -0
  122. package/dist/search-terms.d.ts +2 -0
  123. package/dist/search-terms.d.ts.map +1 -1
  124. package/dist/search-terms.js +5 -1
  125. package/dist/search-terms.js.map +1 -1
  126. package/dist/types-search.d.ts +1 -2
  127. package/dist/types-search.d.ts.map +1 -1
  128. package/dist/types-search.js +33 -16
  129. package/dist/types-search.js.map +1 -1
  130. package/dist/verification.d.ts +9 -0
  131. package/dist/verification.d.ts.map +1 -1
  132. package/dist/verification.js +17 -1
  133. package/dist/verification.js.map +1 -1
  134. package/dist/work-graph.d.ts +123 -0
  135. package/dist/work-graph.d.ts.map +1 -0
  136. package/dist/work-graph.js +465 -0
  137. package/dist/work-graph.js.map +1 -0
  138. package/docs/examples.json +32 -2
  139. package/docs/llms-full.txt +906 -15
  140. package/docs/types.json +1682 -130
  141. package/package.json +34 -9
  142. package/src/assure-hydration.ts +606 -0
  143. package/src/assure.ts +316 -2
  144. package/src/capabilities-tool.ts +80 -1
  145. package/src/check.ts +873 -168
  146. package/src/cli.ts +198 -11
  147. package/src/contract-proof.ts +194 -0
  148. package/src/contracts.ts +157 -0
  149. package/src/diagnostics.ts +32 -0
  150. package/src/diff-tool.ts +1 -1
  151. package/src/docs-search.ts +45 -15
  152. package/src/doctor.ts +259 -8
  153. package/src/examples.ts +43 -15
  154. package/src/fix-recipes.ts +126 -0
  155. package/src/init-agents.ts +61 -4
  156. package/src/internal/source-facts.ts +133 -0
  157. package/src/internal/typescript-import.ts +29 -0
  158. package/src/introspect.ts +75 -6
  159. package/src/load.ts +6 -0
  160. package/src/manifest-tool.ts +16 -10
  161. package/src/mcp-docs-tools.ts +3 -0
  162. package/src/mcp-http.ts +1 -1
  163. package/src/mcp-root.ts +271 -0
  164. package/src/mcp-run.ts +19 -4
  165. package/src/mcp.ts +438 -32
  166. package/src/project-path.ts +36 -0
  167. package/src/replay.ts +44 -0
  168. package/src/rules/codes.ts +57 -0
  169. package/src/rules/comment-markers.ts +26 -0
  170. package/src/rules/index.ts +175 -0
  171. package/src/rules/legacy.ts +93 -0
  172. package/src/rules/routes.ts +157 -0
  173. package/src/rules/security.ts +505 -0
  174. package/src/search-terms.ts +9 -4
  175. package/src/types-search.ts +39 -15
  176. package/src/verification.ts +18 -1
  177. package/src/work-graph.ts +613 -0
package/src/mcp.ts CHANGED
@@ -25,18 +25,23 @@
25
25
  * - `nifra_inspect` - read the running dev server's recent request traces (method/path/status/
26
26
  * duration/ISR) from the DevTools plugin: what your requests ACTUALLY did, not a guess.
27
27
  *
28
- * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
29
- * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
28
+ * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]` (or
29
+ * `["mcp", "<dir>"]` to pin the project directory explicitly). The server does NOT silently trust its
30
+ * spawn directory: the root is resolved via `./mcp-root.ts` (marker walk-up, the client's MCP `roots`,
31
+ * fail-closed tools when no nifra project is found) and announced in `initialize` + on every project
32
+ * tool result. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
30
33
  * standard MCP progress notifications and request cancellation - no SDK dependency, the same
31
34
  * minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
32
35
  * module is the I/O shell (stdin loop, tool wiring, the run subprocess).
33
36
  */
34
37
 
38
+ import { realpathSync } from "node:fs"
35
39
  import { readFile, stat } from "node:fs/promises"
36
- import { basename, resolve, sep } from "node:path"
40
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path"
37
41
  import { fileURLToPath } from "node:url"
38
42
  import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
39
43
  import { Glob } from "bun"
44
+ import { collectContractProof } from "./contract-proof.ts"
40
45
  import { loadDocsCorpus } from "./docs-search.ts"
41
46
  import { loadExamplesCorpus } from "./examples.ts"
42
47
  import { describeProject } from "./introspect.ts"
@@ -49,6 +54,7 @@ import {
49
54
  type JsonRpcNotification,
50
55
  type JsonRpcRequest,
51
56
  type JsonRpcResponse,
57
+ type McpContentBlock,
52
58
  type McpPrompt,
53
59
  type McpPromptMessage,
54
60
  type McpResource,
@@ -58,7 +64,19 @@ import {
58
64
  type McpToolResult,
59
65
  rpcError,
60
66
  } from "./mcp-protocol.ts"
67
+ import {
68
+ applyClientRoots,
69
+ detectToolingDrift,
70
+ driftNote,
71
+ type McpRootVerdict,
72
+ pathsFromRootsResult,
73
+ resolveRootState,
74
+ rootInstructions,
75
+ rootVerdict,
76
+ type ToolingDrift,
77
+ } from "./mcp-root.ts"
61
78
  import { loadTypesCorpus } from "./types-search.ts"
79
+ import { collectProjectWorkGraph } from "./work-graph.ts"
62
80
 
63
81
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
64
82
  * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
@@ -691,7 +709,31 @@ export function projectFeatures(
691
709
  export function resolveProjectDir(root: string, dir: string | undefined): string | null {
692
710
  if (dir === undefined || dir === "") return root
693
711
  const target = resolve(root, dir)
694
- return target === root || target.startsWith(root + sep) ? target : null
712
+ let canonicalRoot: string
713
+ try {
714
+ canonicalRoot = realpathSync(root)
715
+ } catch {
716
+ const lexical = relative(root, target)
717
+ return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null
718
+ }
719
+ let probe = target
720
+ const missing: string[] = []
721
+ while (true) {
722
+ try {
723
+ const canonicalProbe = realpathSync(probe)
724
+ const rel = relative(canonicalRoot, canonicalProbe)
725
+ if (rel.startsWith("..") || isAbsolute(rel)) return null
726
+ return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse())
727
+ } catch {
728
+ const parent = resolve(probe, "..")
729
+ if (parent === probe) return null
730
+ // `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
731
+ // separator is part of it, so `parent.length + 1` eats the first character of the segment and
732
+ // the path gets rebuilt wrong.
733
+ missing.push(basename(probe))
734
+ probe = parent
735
+ }
736
+ }
695
737
  }
696
738
 
697
739
  /** Consistent error string for a `dir` that escapes the project root. */
@@ -711,6 +753,122 @@ export function projectTools(
711
753
  const warmRun = createWarmHandler("mcp-run", cwd, "run")
712
754
  const warmRender = createWarmHandler("mcp-render", cwd, "render")
713
755
  return [
756
+ {
757
+ name: "nifra_contract_proof",
758
+ description:
759
+ "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.",
760
+ inputSchema: {
761
+ type: "object",
762
+ properties: {
763
+ baseline: {
764
+ type: "string",
765
+ description:
766
+ "Snapshot path relative to the selected project directory. Defaults to api-snapshot.json.",
767
+ },
768
+ check: {
769
+ type: "boolean",
770
+ description: "Also run the typed-contract check. Default false to keep the diff lazy.",
771
+ },
772
+ dir: {
773
+ type: "string",
774
+ description: "Project subdirectory to inspect.",
775
+ },
776
+ },
777
+ additionalProperties: false,
778
+ },
779
+ handler: async (args) => {
780
+ const raw = args as { baseline?: unknown; check?: unknown; dir?: unknown }
781
+ if (raw.dir !== undefined && typeof raw.dir !== "string") {
782
+ return JSON.stringify({ ok: false, error: "dir must be a string" }, null, 2)
783
+ }
784
+ if (raw.baseline !== undefined && typeof raw.baseline !== "string") {
785
+ return JSON.stringify({ ok: false, error: "baseline must be a string" }, null, 2)
786
+ }
787
+ const dir = raw.dir as string | undefined
788
+ const baseline = raw.baseline as string | undefined
789
+ const target = resolveProjectDir(cwd, dir)
790
+ if (target === null) return dirError(dir)
791
+ if (baseline !== undefined && baseline.trim() === "") {
792
+ return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
793
+ }
794
+ try {
795
+ return JSON.stringify(
796
+ await collectContractProof(target, {
797
+ ...(baseline === undefined ? {} : { baselinePath: baseline }),
798
+ ...(raw.check === undefined ? {} : { check: raw.check === true }),
799
+ }),
800
+ null,
801
+ 2,
802
+ )
803
+ } catch (error) {
804
+ return JSON.stringify(
805
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
806
+ null,
807
+ 2,
808
+ )
809
+ }
810
+ },
811
+ },
812
+ {
813
+ name: "nifra_prove",
814
+ description:
815
+ "Build the static verification work graph for this project. Pass changed files to get only impacted routes and the cheapest proof plan. The result includes a serializable evidence bundle and a machine-checkable stop condition. Requires a fresh build and never probes a running application.",
816
+ inputSchema: {
817
+ type: "object",
818
+ properties: {
819
+ files: {
820
+ type: "array",
821
+ items: { type: "string" },
822
+ description: "Project-relative changed files. Omit for a graph overview.",
823
+ },
824
+ minLevel: {
825
+ type: "integer",
826
+ minimum: 0,
827
+ maximum: 4,
828
+ description: "Required proof level for the stop condition. Defaults to 1.",
829
+ },
830
+ },
831
+ additionalProperties: false,
832
+ },
833
+ handler: async (args) => {
834
+ const files = args.files
835
+ const minLevel = args.minLevel
836
+ if (
837
+ (files !== undefined &&
838
+ (!Array.isArray(files) || files.some((file) => typeof file !== "string"))) ||
839
+ (minLevel !== undefined &&
840
+ (typeof minLevel !== "number" ||
841
+ !Number.isInteger(minLevel) ||
842
+ minLevel < 0 ||
843
+ minLevel > 4))
844
+ ) {
845
+ return JSON.stringify(
846
+ {
847
+ ok: false,
848
+ error: "files must be strings and minLevel must be an integer from 0 to 4",
849
+ },
850
+ null,
851
+ 2,
852
+ )
853
+ }
854
+ try {
855
+ return JSON.stringify(
856
+ await collectProjectWorkGraph(cwd, {
857
+ ...(files === undefined ? {} : { changedFiles: files }),
858
+ ...(minLevel === undefined ? {} : { minLevel }),
859
+ }),
860
+ null,
861
+ 2,
862
+ )
863
+ } catch (error) {
864
+ return JSON.stringify(
865
+ { ok: false, error: error instanceof Error ? error.message : "work graph failed" },
866
+ null,
867
+ 2,
868
+ )
869
+ }
870
+ },
871
+ },
714
872
  {
715
873
  name: "nifra_context",
716
874
  description:
@@ -1145,15 +1303,18 @@ export function projectTools(
1145
1303
  const opts = args as { lintsOnly?: boolean; dir?: string }
1146
1304
  const target = resolveProjectDir(cwd, opts.dir)
1147
1305
  if (target === null) return dirError(opts.dir)
1306
+ const result = await collectCheckResult(target, {
1307
+ lintsOnly: opts.lintsOnly ?? false,
1308
+ signal: context.signal,
1309
+ // Bound the result so a large project can't emit an MCP message big enough to break the stdio
1310
+ // transport (`-32000: Connection closed`). If `truncated` comes back, fix the shown diagnostics
1311
+ // and re-run. The scan already skips gitignored trees (walkSource), so this is the safety net.
1312
+ maxDiagnostics: 100,
1313
+ })
1148
1314
  return JSON.stringify(
1149
- await collectCheckResult(target, {
1150
- lintsOnly: opts.lintsOnly ?? false,
1151
- signal: context.signal,
1152
- // Bound the result so a large project can't emit an MCP message big enough to break the stdio
1153
- // transport (`-32000: Connection closed`). If `truncated` comes back, fix the shown diagnostics
1154
- // and re-run. The scan already skips gitignored trees (walkSource), so this is the safety net.
1155
- maxDiagnostics: 100,
1156
- }),
1315
+ result.structuredDiagnostics === undefined
1316
+ ? result
1317
+ : { ...result, diagnostics: result.structuredDiagnostics },
1157
1318
  null,
1158
1319
  2,
1159
1320
  )
@@ -1165,14 +1326,21 @@ export function projectTools(
1165
1326
  "Automatically fix diagnostic lints (such as rewriting hand-rolled fetch() calls to the typed nifra client, adding generic types to client factory calls, and resolving dependency drift in package.json). Runs diagnostics, applies all mechanical edit suggestions, applies doctor dependency fixes, and returns the remaining unresolved diagnostics.",
1166
1327
  inputSchema: {
1167
1328
  type: "object",
1168
- properties: {},
1329
+ properties: {
1330
+ code: {
1331
+ type: "string",
1332
+ description:
1333
+ "Apply the registered recipe for one stable diagnostic code when available.",
1334
+ },
1335
+ },
1169
1336
  additionalProperties: false,
1170
1337
  },
1171
- handler: async (_args, context) => {
1338
+ handler: async (args, context) => {
1172
1339
  const { collectCheckResult } = await import("./check.ts")
1340
+ const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
1173
1341
  const { applyDoctorAutoFix } = await import("./doctor.ts")
1342
+ const { resolveInsideProject } = await import("./project-path.ts")
1174
1343
  const { writeFile, readFile } = await import("node:fs/promises")
1175
- const { resolve } = await import("node:path")
1176
1344
 
1177
1345
  let doctorResult: Awaited<ReturnType<typeof applyDoctorAutoFix>> | null = null
1178
1346
  try {
@@ -1187,6 +1355,15 @@ export function projectTools(
1187
1355
  maxDiagnostics: 100,
1188
1356
  })
1189
1357
 
1358
+ const requestedCode = (args as { code?: unknown }).code
1359
+ const recipeFixed: string[] = []
1360
+ if (typeof requestedCode === "string") {
1361
+ for (const diagnostic of checkResult.structuredDiagnostics ?? []) {
1362
+ if (diagnostic.code !== requestedCode) continue
1363
+ recipeFixed.push(...(await applyDiagnosticRecipe(cwd, diagnostic)))
1364
+ }
1365
+ }
1366
+
1190
1367
  const fixed: Array<{ file: string; line: number; title: string }> = []
1191
1368
 
1192
1369
  for (const diag of checkResult.diagnostics) {
@@ -1196,7 +1373,8 @@ export function projectTools(
1196
1373
  const beforeLine = diffLines.find((l) => l.startsWith("-"))?.slice(1)
1197
1374
  const afterLine = diffLines.find((l) => l.startsWith("+"))?.slice(1)
1198
1375
  if (beforeLine !== undefined && afterLine !== undefined) {
1199
- const filePath = resolve(cwd, diag.file)
1376
+ const filePath = await resolveInsideProject(cwd, diag.file)
1377
+ if (filePath === undefined) continue
1200
1378
  const content = await readFile(filePath, "utf-8")
1201
1379
  const lines = content.split("\n")
1202
1380
  const idx = diag.line - 1
@@ -1226,6 +1404,7 @@ export function projectTools(
1226
1404
  {
1227
1405
  ok: finalResult.ok,
1228
1406
  fixed,
1407
+ recipeFixed,
1229
1408
  doctorFixed: doctorResult?.fixed ?? [],
1230
1409
  remainingDiagnostics: finalResult.diagnostics,
1231
1410
  },
@@ -1246,6 +1425,22 @@ export function projectTools(
1246
1425
  description:
1247
1426
  "Config path relative to the selected project directory. Default: nifra.assurance.ts.",
1248
1427
  },
1428
+ bundle: {
1429
+ type: "boolean",
1430
+ description: "Return the single gate bundle with verdict and explicit skipped gates.",
1431
+ },
1432
+ strict: {
1433
+ type: "boolean",
1434
+ description: "Treat warning diagnostics as failures in the bundle.",
1435
+ },
1436
+ hydration: {
1437
+ type: "boolean",
1438
+ description: "Run the hydration assurance gate.",
1439
+ },
1440
+ interact: {
1441
+ type: "boolean",
1442
+ description: "Run declared hydration probes after the client mounts.",
1443
+ },
1249
1444
  dir: {
1250
1445
  type: "string",
1251
1446
  description:
@@ -1255,7 +1450,14 @@ export function projectTools(
1255
1450
  additionalProperties: false,
1256
1451
  },
1257
1452
  handler: async (args) => {
1258
- const opts = args as { config?: string; dir?: string }
1453
+ const opts = args as {
1454
+ config?: string
1455
+ dir?: string
1456
+ bundle?: boolean
1457
+ strict?: boolean
1458
+ hydration?: boolean
1459
+ interact?: boolean
1460
+ }
1259
1461
  const target = resolveProjectDir(cwd, opts.dir)
1260
1462
  if (target === null) return dirError(opts.dir)
1261
1463
  const config = opts.config === undefined ? undefined : resolve(target, opts.config)
@@ -1266,10 +1468,72 @@ export function projectTools(
1266
1468
  2,
1267
1469
  )
1268
1470
  }
1269
- const { collectAssuranceReport } = await import("./assure.ts")
1471
+ const { collectAssuranceReport, collectAssureBundle } = await import("./assure.ts")
1472
+ if (opts.bundle === true || opts.strict === true || opts.hydration === true) {
1473
+ return JSON.stringify(
1474
+ await collectAssureBundle(target, {
1475
+ ...(config === undefined ? {} : { config }),
1476
+ ...(opts.strict === undefined ? {} : { strict: opts.strict }),
1477
+ ...(opts.hydration === undefined ? {} : { hydration: opts.hydration }),
1478
+ ...(opts.interact === undefined ? {} : { interact: opts.interact }),
1479
+ }),
1480
+ null,
1481
+ 2,
1482
+ )
1483
+ }
1270
1484
  return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
1271
1485
  },
1272
1486
  },
1487
+ {
1488
+ name: "nifra_hydrate",
1489
+ description: "Run the hydration assurance gate and return stable NF-H diagnostics.",
1490
+ inputSchema: {
1491
+ type: "object",
1492
+ properties: {
1493
+ dir: { type: "string", description: "Project subdirectory to inspect." },
1494
+ interact: { type: "boolean", description: "Run declared hydration probes." },
1495
+ },
1496
+ additionalProperties: false,
1497
+ },
1498
+ handler: async (args) => {
1499
+ const opts = args as { dir?: string; interact?: boolean }
1500
+ const target = resolveProjectDir(cwd, opts.dir)
1501
+ if (target === null) return dirError(opts.dir)
1502
+ const { runHydrationAssurance } = await import("./assure-hydration.ts")
1503
+ return JSON.stringify(
1504
+ await runHydrationAssurance(target, { interact: opts.interact === true }),
1505
+ null,
1506
+ 2,
1507
+ )
1508
+ },
1509
+ },
1510
+ {
1511
+ name: "nifra_replay",
1512
+ description: "Validate and dispatch a token-only replay metadata file.",
1513
+ inputSchema: {
1514
+ type: "object",
1515
+ properties: {
1516
+ file: { type: "string", description: "Replay file relative to the project root." },
1517
+ },
1518
+ required: ["file"],
1519
+ additionalProperties: false,
1520
+ },
1521
+ handler: async (args) => {
1522
+ const file = (args as { file?: unknown }).file
1523
+ if (typeof file !== "string" || file.trim() === "")
1524
+ return JSON.stringify({ ok: false, error: "file is required" })
1525
+ const { runReplay } = await import("./replay.ts")
1526
+ try {
1527
+ return JSON.stringify(await runReplay(cwd, file), null, 2)
1528
+ } catch (error) {
1529
+ return JSON.stringify(
1530
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
1531
+ null,
1532
+ 2,
1533
+ )
1534
+ }
1535
+ },
1536
+ },
1273
1537
  {
1274
1538
  name: "nifra_levels",
1275
1539
  description:
@@ -1544,13 +1808,27 @@ async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTo
1544
1808
  }
1545
1809
  }
1546
1810
 
1547
- /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
1548
- export async function runMcpServer(cwd: string, version: string): Promise<void> {
1549
- let features: McpServerFeatures
1811
+ /** Tool names that never touch the project - they serve the bundled docs/examples/types corpus, so
1812
+ * they stay available (and unstamped) whatever the root state is. */
1813
+ const PROJECT_FREE_TOOLS = new Set(["nifra_docs", "nifra_example", "nifra_types", "nifra_learn"])
1814
+
1815
+ /** The id of this server's own `roots/list` request to the client. A fixed, namespaced string: the
1816
+ * server only ever has one roots request in flight, and no response to a CLIENT-initiated request can
1817
+ * carry it (clients pick their own ids; this one is recognisably ours). */
1818
+ const ROOTS_REQUEST_ID = "nifra:roots/list"
1819
+
1820
+ /** Everything derived from the project root - rebuilt wholesale when the root changes (adoption of a
1821
+ * client workspace root), so no per-tool state can keep pointing at the old directory. */
1822
+ interface ProjectContext {
1823
+ readonly monorepo: Awaited<ReturnType<typeof detectMonorepo>>
1824
+ readonly features: McpServerFeatures
1825
+ readonly loadAppCached: () => Promise<LoadedApp>
1826
+ }
1550
1827
 
1551
- const monorepo = await detectMonorepo(cwd)
1828
+ async function createProjectContext(root: string): Promise<ProjectContext> {
1829
+ const monorepo = await detectMonorepo(root)
1552
1830
  if (monorepo) {
1553
- const appEntries = await loadMonorepoApps(cwd, monorepo)
1831
+ const appEntries = await loadMonorepoApps(root, monorepo)
1554
1832
  const allResources: McpResource[] = []
1555
1833
  const allPrompts: McpPrompt[] = []
1556
1834
  for (const { name, cwd: appCwd } of appEntries) {
@@ -1563,21 +1841,130 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1563
1841
  allResources.push(...(ns.features.resources ?? []))
1564
1842
  allPrompts.push(...(ns.features.prompts ?? []))
1565
1843
  }
1566
- features = { resources: allResources, prompts: allPrompts }
1567
- } else {
1568
- const loadAppCached = createCachedAppLoader(cwd)
1569
- features = await backendFeatures(loadAppCached, projectFeatures(cwd, loadAppCached))
1844
+ return {
1845
+ monorepo,
1846
+ features: { resources: allResources, prompts: allPrompts },
1847
+ loadAppCached: createCachedAppLoader(root),
1848
+ }
1849
+ }
1850
+ const loadAppCached = createCachedAppLoader(root)
1851
+ return {
1852
+ monorepo,
1853
+ features: await backendFeatures(loadAppCached, projectFeatures(root, loadAppCached)),
1854
+ loadAppCached,
1570
1855
  }
1571
- const loadAppCached = createCachedAppLoader(cwd)
1856
+ }
1857
+
1858
+ /**
1859
+ * Wrap every project-scoped tool with the root guard: a bad root ({@link rootVerdict} `blocked`) makes
1860
+ * the tool refuse with the remediation message instead of answering about whatever directory the
1861
+ * server happened to start in; a good root stamps the result with `[nifra] project root: …` so the
1862
+ * agent (and the human reading its transcript) can see WHICH project answered. Docs tools pass
1863
+ * through untouched - they serve the bundled corpus, not the project.
1864
+ */
1865
+ export function guardTools(tools: McpTool[], verdict: McpRootVerdict): McpTool[] {
1866
+ return tools.map((tool) => {
1867
+ if (PROJECT_FREE_TOOLS.has(tool.name)) return tool
1868
+ return {
1869
+ ...tool,
1870
+ handler: async (
1871
+ args: Record<string, unknown>,
1872
+ context: McpToolContext,
1873
+ ): Promise<McpToolResult> => {
1874
+ if (verdict.blocked !== undefined) {
1875
+ return { content: [{ type: "text", text: verdict.blocked }], isError: true }
1876
+ }
1877
+ const result = await tool.handler(args, context)
1878
+ const note: McpContentBlock = { type: "text", text: verdict.note }
1879
+ if (typeof result === "string") {
1880
+ return { content: [{ type: "text", text: result }, note] }
1881
+ }
1882
+ return { ...result, content: [...(result.content ?? []), note] }
1883
+ },
1884
+ }
1885
+ })
1886
+ }
1887
+
1888
+ /** Whether the client's `initialize` params declare the `roots` capability - the precondition for
1889
+ * this server sending it a `roots/list` request. */
1890
+ export function clientSupportsRoots(params: Record<string, unknown> | undefined): boolean {
1891
+ const capabilities = params?.capabilities
1892
+ if (typeof capabilities !== "object" || capabilities === null) return false
1893
+ const roots = (capabilities as { roots?: unknown }).roots
1894
+ return typeof roots === "object" && roots !== null
1895
+ }
1896
+
1897
+ /**
1898
+ * Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout.
1899
+ *
1900
+ * The project root is resolved defensively rather than assumed (`./mcp-root.ts`): explicit
1901
+ * `nifra mcp <dir>` beats a cwd guess, a cwd guess walks up to the nearest nifra marker, the client's
1902
+ * MCP `roots` (requested after the handshake, re-requested on `roots/list_changed`) can correct an
1903
+ * unambiguous wrong guess, and anything still unresolved fails closed per tool call. The effective
1904
+ * root is announced in the `initialize` instructions and stamped on every project tool result.
1905
+ */
1906
+ export async function runMcpServer(
1907
+ cwd: string,
1908
+ version: string,
1909
+ explicitDir?: string,
1910
+ ): Promise<void> {
1911
+ const requested = explicitDir !== undefined ? resolve(cwd, explicitDir) : cwd
1912
+ if (explicitDir !== undefined && !(await stat(requested).catch(() => null))?.isDirectory()) {
1913
+ throw new Error(`nifra mcp: directory not found: ${requested}`)
1914
+ }
1915
+ let rootState = await resolveRootState(requested, explicitDir !== undefined)
1916
+ let ctx = await createProjectContext(rootState.root)
1917
+ // The CLI answering may not be the nifra the project builds with (a globally installed server, a
1918
+ // stale agent config). Recomputed whenever the root moves, since a different project can install a
1919
+ // different version.
1920
+ let drift: ToolingDrift | undefined = await detectToolingDrift(rootState.root, version)
1921
+
1572
1922
  const serverInfo = { name: "nifra", version }
1573
1923
  const state = createMcpProtocolState()
1574
1924
  const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
1575
1925
  process.stdout.write(`${JSON.stringify(message)}\n`)
1576
1926
  }
1927
+ let rootsSupported = false
1928
+ const requestRoots = (): void => {
1929
+ if (!rootsSupported) return
1930
+ process.stdout.write(
1931
+ `${JSON.stringify({ jsonrpc: "2.0", id: ROOTS_REQUEST_ID, method: "roots/list" })}\n`,
1932
+ )
1933
+ }
1934
+ const onRootsAnswer = async (message: JsonRpcRequest): Promise<void> => {
1935
+ const result = (message as { result?: unknown }).result
1936
+ // An error response (or a malformed one) carries no roots: keep the current state - the absence
1937
+ // of workspace data is not a mismatch, and never a reason to move the root.
1938
+ if (result === undefined) return
1939
+ const next = await applyClientRoots(rootState, pathsFromRootsResult(result))
1940
+ if (next.root !== rootState.root) {
1941
+ ctx = await createProjectContext(next.root)
1942
+ drift = await detectToolingDrift(next.root, version)
1943
+ }
1944
+ rootState = next
1945
+ }
1946
+
1947
+ // Root updates are serialized against tool dispatch: a message read AFTER the client's roots answer
1948
+ // must see the adopted root, even though dispatches themselves run concurrently.
1949
+ let rootsUpdate: Promise<void> = Promise.resolve()
1950
+
1577
1951
  const dispatch = async (message: JsonRpcRequest): Promise<void> => {
1952
+ // The client's answer to OUR `roots/list` request - a response (id, no method), which the pure
1953
+ // dispatch would reject as an unknown method. Intercept it before handleRpc ever sees it.
1954
+ if (message.method === undefined && message.id === ROOTS_REQUEST_ID) {
1955
+ // Swallow failures: a bad answer must not poison the chain and 500 every later dispatch.
1956
+ rootsUpdate = onRootsAnswer(message).catch(() => {})
1957
+ await rootsUpdate
1958
+ return
1959
+ }
1960
+ await rootsUpdate
1961
+ if (message.method === "initialize") {
1962
+ rootsSupported = clientSupportsRoots(message.params)
1963
+ }
1964
+
1578
1965
  let activeTools: McpTool[]
1579
- if (monorepo) {
1580
- const appEntries = await loadMonorepoApps(cwd, monorepo)
1966
+ if (ctx.monorepo) {
1967
+ const appEntries = await loadMonorepoApps(rootState.root, ctx.monorepo)
1581
1968
  const allTools: McpTool[] = []
1582
1969
  for (const { name, cwd: appCwd } of appEntries) {
1583
1970
  const loader = createCachedAppLoader(appCwd)
@@ -1588,16 +1975,35 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1588
1975
  activeTools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
1589
1976
  } else {
1590
1977
  activeTools = [
1591
- ...projectTools(cwd, loadAppCached),
1592
- ...(await appDeclaredTools(loadAppCached)),
1978
+ ...projectTools(rootState.root, ctx.loadAppCached),
1979
+ ...(await appDeclaredTools(ctx.loadAppCached)),
1593
1980
  ]
1594
1981
  }
1982
+ const verdict = await rootVerdict(rootState)
1983
+ activeTools = guardTools(
1984
+ activeTools,
1985
+ drift === undefined || verdict.blocked !== undefined
1986
+ ? verdict
1987
+ : { ...verdict, note: `${verdict.note}\n${driftNote(drift)}` },
1988
+ )
1595
1989
 
1990
+ const features: McpServerFeatures = {
1991
+ ...ctx.features,
1992
+ instructions: rootInstructions(rootState, drift),
1993
+ }
1596
1994
  const response = await handleRpc(message, activeTools, serverInfo, features, {
1597
1995
  state,
1598
1996
  sendNotification: send,
1599
1997
  })
1600
1998
  if (response) send(response)
1999
+ // Ask for the client's workspace roots once the handshake completes, and again whenever the
2000
+ // client says they changed. The answer comes back through the interception above.
2001
+ if (
2002
+ message.method === "notifications/initialized" ||
2003
+ message.method === "notifications/roots/list_changed"
2004
+ ) {
2005
+ requestRoots()
2006
+ }
1601
2007
  }
1602
2008
 
1603
2009
  const decoder = new TextDecoder()
@@ -0,0 +1,36 @@
1
+ import { realpath } from "node:fs/promises"
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"
3
+
4
+ function escapesRoot(root: string, candidate: string): boolean {
5
+ const rel = relative(root, candidate)
6
+ return rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)
7
+ }
8
+
9
+ /** Resolve a diagnostic path only when both its lexical and filesystem paths stay in the project. */
10
+ export async function resolveInsideProject(
11
+ root: string,
12
+ candidate: string,
13
+ ): Promise<string | undefined> {
14
+ try {
15
+ if (isAbsolute(candidate)) return undefined
16
+ const resolvedRoot = resolve(root)
17
+ const resolved = resolve(resolvedRoot, candidate)
18
+ if (escapesRoot(resolvedRoot, resolved)) return undefined
19
+
20
+ const realRoot = await realpath(resolvedRoot)
21
+ let realCandidate: string
22
+ try {
23
+ realCandidate = await realpath(resolved)
24
+ } catch (error) {
25
+ if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT")) {
26
+ return undefined
27
+ }
28
+ const realParent = await realpath(dirname(resolved))
29
+ realCandidate = resolve(realParent, basename(resolved))
30
+ }
31
+ if (escapesRoot(realRoot, realCandidate)) return undefined
32
+ return resolved
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }