@nifrajs/cli 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- 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"]
|
|
29
|
-
* project
|
|
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
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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
|
-
/**
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
|
|
1828
|
+
async function createProjectContext(root: string): Promise<ProjectContext> {
|
|
1829
|
+
const monorepo = await detectMonorepo(root)
|
|
1552
1830
|
if (monorepo) {
|
|
1553
|
-
const appEntries = await loadMonorepoApps(
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|