@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-docs-tools.ts
CHANGED
|
@@ -35,6 +35,7 @@ export function docsTools(
|
|
|
35
35
|
properties: {
|
|
36
36
|
query: {
|
|
37
37
|
type: "string",
|
|
38
|
+
maxLength: 256,
|
|
38
39
|
description: "Keywords to match against doc sections (omit for the index).",
|
|
39
40
|
},
|
|
40
41
|
limit: { type: "number", description: "Max sections to return (default 3, max 5)." },
|
|
@@ -64,6 +65,7 @@ export function docsTools(
|
|
|
64
65
|
properties: {
|
|
65
66
|
query: {
|
|
66
67
|
type: "string",
|
|
68
|
+
maxLength: 256,
|
|
67
69
|
description: "What you want an example of (omit for the index).",
|
|
68
70
|
},
|
|
69
71
|
limit: { type: "number", description: "Max examples to return (default 3, max 5)." },
|
|
@@ -94,6 +96,7 @@ export function docsTools(
|
|
|
94
96
|
},
|
|
95
97
|
query: {
|
|
96
98
|
type: "string",
|
|
99
|
+
maxLength: 256,
|
|
97
100
|
description:
|
|
98
101
|
"Keyword search over names + signatures (when you don't know the exact name).",
|
|
99
102
|
},
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "2.
|
|
32
|
+
const VERSION = "2.12.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|
package/src/mcp-root.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-root resolution for the stdio MCP server - the answer to the "silently rooted at the wrong
|
|
3
|
+
* project" footgun. The server never trusts a bare `process.cwd()` again:
|
|
4
|
+
*
|
|
5
|
+
* 1. An explicit `nifra mcp <dir>` argument is taken literally (human configuration wins).
|
|
6
|
+
* 2. A cwd guess walks UP to the nearest nifra marker (a `package.json` depending on `@nifrajs/*`,
|
|
7
|
+
* or a `nifra.config.ts` monorepo root), so running from a subdirectory still finds the project.
|
|
8
|
+
* 3. The client's MCP `roots` (its workspace folders) are requested after the handshake. When the
|
|
9
|
+
* guess found no project - or found one DISJOINT from every workspace root - and exactly one
|
|
10
|
+
* workspace root is a nifra project, the server adopts it.
|
|
11
|
+
* 4. What cannot be resolved fails CLOSED: project-scoped tools refuse with a remediation message
|
|
12
|
+
* instead of describing whatever directory the server happened to start in.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure state + fs probes; the I/O wiring lives in `./mcp.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile } from "node:fs/promises"
|
|
18
|
+
import { dirname, sep } from "node:path"
|
|
19
|
+
import { fileURLToPath } from "node:url"
|
|
20
|
+
|
|
21
|
+
/** How the current root was chosen. `arg` = explicit `nifra mcp <dir>`; `cwd` = the spawn directory;
|
|
22
|
+
* `parent` = a marker found walking up from cwd; `client-root` = adopted from the client's MCP roots. */
|
|
23
|
+
export type McpRootSource = "arg" | "cwd" | "parent" | "client-root"
|
|
24
|
+
|
|
25
|
+
export interface McpRootState {
|
|
26
|
+
/** Absolute directory every project-scoped tool operates on. */
|
|
27
|
+
readonly root: string
|
|
28
|
+
readonly source: McpRootSource
|
|
29
|
+
/** Whether `root` carries a nifra marker. `false` fails project tools closed. */
|
|
30
|
+
readonly isProject: boolean
|
|
31
|
+
/** The client's workspace roots (absolute paths), or `null` until (unless) the client answers
|
|
32
|
+
* `roots/list`. `null` disables mismatch detection - no data is not a mismatch. */
|
|
33
|
+
readonly clientRoots: readonly string[] | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A directory is a nifra project when its `package.json` depends on any `@nifrajs/*` package, or it
|
|
37
|
+
* is a `nifra.config.ts` monorepo root. Backend-only projects (no web config, no `routes/`) count -
|
|
38
|
+
* the marker is the dependency, not the app shape. */
|
|
39
|
+
export async function isNifraProjectDir(dir: string): Promise<boolean> {
|
|
40
|
+
if (await Bun.file(`${dir}${sep}nifra.config.ts`).exists()) return true
|
|
41
|
+
try {
|
|
42
|
+
const pkg = JSON.parse(await readFile(`${dir}${sep}package.json`, "utf8")) as Record<
|
|
43
|
+
string,
|
|
44
|
+
unknown
|
|
45
|
+
>
|
|
46
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
47
|
+
const deps = pkg[section]
|
|
48
|
+
if (typeof deps !== "object" || deps === null) continue
|
|
49
|
+
for (const name of Object.keys(deps)) if (name.startsWith("@nifrajs/")) return true
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// No package.json, or unparseable - not a project marker either way.
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Nearest ancestor (including `start` itself) that is a nifra project, or `null`. */
|
|
58
|
+
export async function findNifraRoot(start: string): Promise<string | null> {
|
|
59
|
+
let dir = start
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (await isNifraProjectDir(dir)) return dir
|
|
62
|
+
const parent = dirname(dir)
|
|
63
|
+
if (parent === dir) return null
|
|
64
|
+
dir = parent
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Initial root state. An explicit dir is taken literally (no walk-up - the human named it); a cwd
|
|
69
|
+
* guess walks up so a subdirectory spawn still lands on the project. */
|
|
70
|
+
export async function resolveRootState(
|
|
71
|
+
requested: string,
|
|
72
|
+
explicit: boolean,
|
|
73
|
+
): Promise<McpRootState> {
|
|
74
|
+
if (explicit) {
|
|
75
|
+
return {
|
|
76
|
+
root: requested,
|
|
77
|
+
source: "arg",
|
|
78
|
+
isProject: await isNifraProjectDir(requested),
|
|
79
|
+
clientRoots: null,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const found = await findNifraRoot(requested)
|
|
83
|
+
if (found !== null) {
|
|
84
|
+
return {
|
|
85
|
+
root: found,
|
|
86
|
+
source: found === requested ? "cwd" : "parent",
|
|
87
|
+
isProject: true,
|
|
88
|
+
clientRoots: null,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { root: requested, source: "cwd", isProject: false, clientRoots: null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `file://` roots from a `roots/list` result, as absolute paths. Non-file and malformed URIs are
|
|
95
|
+
* skipped - the client owns that list, this server only reads it. */
|
|
96
|
+
export function pathsFromRootsResult(result: unknown): string[] {
|
|
97
|
+
if (typeof result !== "object" || result === null) return []
|
|
98
|
+
const roots = (result as { roots?: unknown }).roots
|
|
99
|
+
if (!Array.isArray(roots)) return []
|
|
100
|
+
const paths: string[] = []
|
|
101
|
+
for (const entry of roots) {
|
|
102
|
+
const uri = (entry as { uri?: unknown })?.uri
|
|
103
|
+
if (typeof uri !== "string" || !uri.startsWith("file://")) continue
|
|
104
|
+
try {
|
|
105
|
+
paths.push(fileURLToPath(uri))
|
|
106
|
+
} catch {
|
|
107
|
+
// Malformed URI - skip.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return paths
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contains = (parent: string, child: string): boolean =>
|
|
114
|
+
parent === child || child.startsWith(parent + sep)
|
|
115
|
+
|
|
116
|
+
/** True when the server root and the client's workspace are DISJOINT - neither contains the other.
|
|
117
|
+
* A server rooted at a monorepo whose sub-app is the open workspace (or vice versa) is not a
|
|
118
|
+
* mismatch; a "server points at repo A, workspace is repo B" split is. */
|
|
119
|
+
export function rootMismatch(state: McpRootState): boolean {
|
|
120
|
+
if (state.clientRoots === null || state.clientRoots.length === 0) return false
|
|
121
|
+
return !state.clientRoots.some(
|
|
122
|
+
(workspaceRoot) => contains(workspaceRoot, state.root) || contains(state.root, workspaceRoot),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fold the client's workspace roots into the state, adopting one of them when the current root is
|
|
128
|
+
* wrong and the correction is unambiguous: the root was GUESSED (never an explicit `nifra mcp <dir>`),
|
|
129
|
+
* it is not a project - or is one disjoint from the whole workspace - and exactly ONE workspace root
|
|
130
|
+
* is a nifra project. Ambiguity (zero or several candidates) adopts nothing; the guard's remediation
|
|
131
|
+
* message lists the candidates instead.
|
|
132
|
+
*/
|
|
133
|
+
export async function applyClientRoots(
|
|
134
|
+
state: McpRootState,
|
|
135
|
+
paths: readonly string[],
|
|
136
|
+
): Promise<McpRootState> {
|
|
137
|
+
const next: McpRootState = { ...state, clientRoots: paths }
|
|
138
|
+
if (state.source === "arg") return next
|
|
139
|
+
if (next.isProject && !rootMismatch(next)) return next
|
|
140
|
+
const candidates: string[] = []
|
|
141
|
+
for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
|
|
142
|
+
if (candidates.length !== 1) return next
|
|
143
|
+
return {
|
|
144
|
+
root: candidates[0] as string,
|
|
145
|
+
source: "client-root",
|
|
146
|
+
isProject: true,
|
|
147
|
+
clientRoots: paths,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The nifra project roots among the client's workspace roots - the remediation list when adoption
|
|
152
|
+
* was ambiguous. */
|
|
153
|
+
export async function nifraCandidates(paths: readonly string[]): Promise<string[]> {
|
|
154
|
+
const candidates: string[] = []
|
|
155
|
+
for (const path of paths) if (await isNifraProjectDir(path)) candidates.push(path)
|
|
156
|
+
return candidates
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const REMEDIATION =
|
|
160
|
+
"Fix: start the client in the project directory, register the server project-scoped " +
|
|
161
|
+
"(`nifra init-agents` writes .mcp.json), or point it explicitly: `nifra mcp <dir>`."
|
|
162
|
+
|
|
163
|
+
/** Per-call verdict for the tool guard: `blocked` set = project tools refuse with that message;
|
|
164
|
+
* otherwise `note` is appended to every project tool result so the effective root stays visible. */
|
|
165
|
+
export interface McpRootVerdict {
|
|
166
|
+
readonly blocked?: string
|
|
167
|
+
readonly note: string
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function rootVerdict(state: McpRootState): Promise<McpRootVerdict> {
|
|
171
|
+
const workspace =
|
|
172
|
+
state.clientRoots !== null && state.clientRoots.length > 0
|
|
173
|
+
? ` Client workspace roots: ${state.clientRoots.join(", ")}.`
|
|
174
|
+
: ""
|
|
175
|
+
if (!state.isProject) {
|
|
176
|
+
const candidates = state.clientRoots !== null ? await nifraCandidates(state.clientRoots) : []
|
|
177
|
+
const hint =
|
|
178
|
+
candidates.length > 1
|
|
179
|
+
? ` Several workspace roots are nifra projects (${candidates.join(", ")}) - pass one: \`nifra mcp <dir>\`.`
|
|
180
|
+
: ""
|
|
181
|
+
return {
|
|
182
|
+
blocked:
|
|
183
|
+
`No nifra project at ${state.root} (no package.json with an @nifrajs/* dependency, no nifra.config.ts). ` +
|
|
184
|
+
`This server only serves the project it is rooted in.${workspace}${hint} ${REMEDIATION}`,
|
|
185
|
+
note: "",
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (rootMismatch(state)) {
|
|
189
|
+
if (state.source === "arg") {
|
|
190
|
+
// Explicit human configuration wins - annotate, don't block.
|
|
191
|
+
return {
|
|
192
|
+
note: `[nifra] project root: ${state.root} (explicit dir, outside the client workspace)`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
blocked:
|
|
197
|
+
`Wrong project: this MCP server is rooted at ${state.root}, which is disjoint from your ` +
|
|
198
|
+
`client's workspace.${workspace} Results would describe a different codebase. ${REMEDIATION}`,
|
|
199
|
+
note: "",
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { note: `[nifra] project root: ${state.root}` }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The `initialize`/`server-discover` instructions line announcing the effective root. */
|
|
206
|
+
export function rootInstructions(state: McpRootState, drift?: ToolingDrift): string {
|
|
207
|
+
if (!state.isProject) {
|
|
208
|
+
return `WARNING: no nifra project at ${state.root} - project tools will refuse until the server is started in (or pointed at) a nifra project. ${REMEDIATION}`
|
|
209
|
+
}
|
|
210
|
+
return `Serving the nifra project at ${state.root}.${drift === undefined ? "" : ` ${driftNote(drift)}`}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A version split between the CLI answering and the nifra the project actually builds with. */
|
|
214
|
+
export interface ToolingDrift {
|
|
215
|
+
/** The running CLI's version. */
|
|
216
|
+
readonly cli: string
|
|
217
|
+
/** The version installed in the project. */
|
|
218
|
+
readonly project: string
|
|
219
|
+
/** Which installed package was compared (`@nifrajs/cli` when present, else `@nifrajs/core`). */
|
|
220
|
+
readonly package: string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One line, on `initialize` and on every project tool result: the answer may describe a different
|
|
224
|
+
* nifra than the project compiles against - stale types, checks, and docs all read as authoritative. */
|
|
225
|
+
export const driftNote = (drift: ToolingDrift): string =>
|
|
226
|
+
`WARNING: this server runs nifra CLI ${drift.cli}, but the project installs ${drift.package} ${drift.project}. ` +
|
|
227
|
+
"Types, checks, and docs may describe a different version than your code builds with. " +
|
|
228
|
+
"Fix: run the project's own CLI (`bunx --bun nifra mcp` from the project directory, or point the client at ./node_modules/.bin/nifra)."
|
|
229
|
+
|
|
230
|
+
/** Feature-level agreement. Patch drift is not reported: it never changes the surface being described. */
|
|
231
|
+
const featureVersion = (version: string): string => version.split(".").slice(0, 2).join(".")
|
|
232
|
+
|
|
233
|
+
async function installedVersion(root: string, name: string): Promise<string | undefined> {
|
|
234
|
+
let dir = root
|
|
235
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
236
|
+
try {
|
|
237
|
+
const meta = JSON.parse(
|
|
238
|
+
await readFile(
|
|
239
|
+
`${dir}${sep}node_modules${sep}${name.split("/").join(sep)}${sep}package.json`,
|
|
240
|
+
"utf8",
|
|
241
|
+
),
|
|
242
|
+
) as { version?: unknown }
|
|
243
|
+
if (typeof meta.version === "string" && meta.version.length > 0) return meta.version
|
|
244
|
+
} catch {
|
|
245
|
+
// Not installed at this level - keep walking up (a monorepo app hoists to the repo root).
|
|
246
|
+
}
|
|
247
|
+
const parent = dirname(dir)
|
|
248
|
+
if (parent === dir) return undefined
|
|
249
|
+
dir = parent
|
|
250
|
+
}
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compare the running CLI against the nifra the project installs. An agent that asks an older (or
|
|
256
|
+
* newer) CLI about a project gets confident answers about a surface the project does not have -
|
|
257
|
+
* the version sprawl is invisible because every answer still looks authoritative.
|
|
258
|
+
*/
|
|
259
|
+
export async function detectToolingDrift(
|
|
260
|
+
root: string,
|
|
261
|
+
cliVersion: string,
|
|
262
|
+
): Promise<ToolingDrift | undefined> {
|
|
263
|
+
for (const name of ["@nifrajs/cli", "@nifrajs/core"]) {
|
|
264
|
+
const project = await installedVersion(root, name)
|
|
265
|
+
if (project === undefined) continue
|
|
266
|
+
return featureVersion(project) === featureVersion(cliVersion)
|
|
267
|
+
? undefined
|
|
268
|
+
: { cli: cliVersion, project, package: name }
|
|
269
|
+
}
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
package/src/mcp-run.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* exported so it's unit-testable in-process; the entry below wires it to stdin/stdout.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync } from "node:fs"
|
|
9
|
-
import { resolve, sep } from "node:path"
|
|
8
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
9
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
10
10
|
import { pathToFileURL } from "node:url"
|
|
11
11
|
import { type AppLike, runApp } from "@nifrajs/runner"
|
|
12
12
|
|
|
@@ -26,8 +26,23 @@ export async function loadBackend(
|
|
|
26
26
|
}
|
|
27
27
|
// The `entry` arg comes from MCP args; importing it executes it. Keep it inside the project root so a
|
|
28
28
|
// crafted `entry` (`../../x`, an absolute path) can't run arbitrary modules outside the project.
|
|
29
|
-
const root = resolve(cwd)
|
|
30
|
-
|
|
29
|
+
const root = realpathSync(resolve(cwd))
|
|
30
|
+
let canonicalFound: string
|
|
31
|
+
try {
|
|
32
|
+
canonicalFound = realpathSync(found)
|
|
33
|
+
} catch {
|
|
34
|
+
return { error: `refusing to load an unresolved backend entry: ${entry}` }
|
|
35
|
+
}
|
|
36
|
+
const rel = relative(root, canonicalFound)
|
|
37
|
+
// `isAbsolute` is not redundant with the `..` test: across two Windows drives `relative()` gives up
|
|
38
|
+
// and returns the absolute target, which starts with neither `..` nor the separator - and then
|
|
39
|
+
// `resolve(root, rel)` returns that same absolute path, so the round-trip check agrees with it too.
|
|
40
|
+
if (
|
|
41
|
+
rel.startsWith("..") ||
|
|
42
|
+
rel === sep ||
|
|
43
|
+
isAbsolute(rel) ||
|
|
44
|
+
resolve(root, rel) !== canonicalFound
|
|
45
|
+
) {
|
|
31
46
|
return { error: `refusing to load a backend entry outside the project root: ${entry}` }
|
|
32
47
|
}
|
|
33
48
|
|