@nifrajs/cli 3.2.0 → 3.5.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/dist/check-diagnostics.d.ts +3 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +149 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +9 -0
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +32 -0
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +63 -8
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -7
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +4 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +28 -7
- package/dist/command-catalog.js.map +1 -1
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +4 -3
- package/dist/contracts.js.map +1 -1
- package/dist/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/introspect.d.ts +1 -3
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +1 -47
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +5 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +4 -5
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +133 -25
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +4 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +12 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +68 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.js.map +1 -0
- package/dist/mcp-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +62 -43
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +14 -2
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -51
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +84 -29
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +23 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- package/dist/project-facts.js.map +1 -1
- package/dist/review.d.ts +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +819 -0
- package/dist/review.js.map +1 -0
- package/dist/rules/legacy.d.ts +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +796 -34
- package/dist/rules/legacy.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +23 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/work-graph.d.ts +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +4 -3
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +10 -4
- package/docs/llms-full.txt +500 -46
- package/docs/types.json +1123 -82
- package/package.json +10 -9
- package/src/check-diagnostics.ts +181 -796
- package/src/check-scan.ts +36 -0
- package/src/check.ts +65 -12
- package/src/cli.ts +53 -7
- package/src/command-catalog.ts +33 -7
- package/src/contracts.ts +12 -3
- package/src/diagnostics.ts +179 -0
- package/src/introspect.ts +3 -55
- package/src/load.ts +5 -0
- package/src/mcp-context.ts +4 -6
- package/src/mcp-exec.ts +142 -30
- package/src/mcp-http.ts +4 -1
- package/src/mcp-io.ts +73 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +70 -40
- package/src/mcp-root.ts +13 -2
- package/src/mcp-run.ts +90 -50
- package/src/mcp.ts +87 -27
- package/src/project-facts.ts +37 -8
- package/src/review.ts +983 -0
- package/src/rules/legacy.ts +915 -35
- package/src/scaffold.ts +29 -4
- package/src/verification-plan.ts +14 -0
- package/src/work-graph.ts +13 -3
package/src/introspect.ts
CHANGED
|
@@ -7,74 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
import { reservedKeyFor } from "@nifrajs/client"
|
|
9
9
|
import {
|
|
10
|
-
type ProjectEvidenceSchema,
|
|
11
10
|
type ProjectEvidenceSchemaPart,
|
|
12
11
|
type ProjectEvidenceSnapshot,
|
|
12
|
+
reflectedRoutesFromEvidence,
|
|
13
13
|
snapshotProjectEvidence,
|
|
14
14
|
} from "@nifrajs/core/evidence"
|
|
15
|
-
import type { ReflectedRoute
|
|
15
|
+
import type { ReflectedRoute } from "@nifrajs/core/reflection"
|
|
16
16
|
import type { Manifest } from "@nifrajs/web"
|
|
17
17
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
18
18
|
import type { LoadedApp } from "./load.ts"
|
|
19
19
|
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
20
20
|
|
|
21
21
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
22
|
-
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
22
|
+
export function backendRoutes(backend: unknown): readonly ReflectedRoute[] {
|
|
23
23
|
return reflectedRoutesFromEvidence(snapshotProjectEvidence(backend))
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function schemaPartToReflection(
|
|
27
|
-
value: ProjectEvidenceSchemaPart | undefined,
|
|
28
|
-
): SchemaReflection | undefined {
|
|
29
|
-
return value === undefined
|
|
30
|
-
? undefined
|
|
31
|
-
: { standard: undefined, jsonSchema: value.jsonSchema, fields: value.fields }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): ReflectedRoute["schema"] {
|
|
35
|
-
if (schema === undefined) return undefined
|
|
36
|
-
const errors = Object.fromEntries(
|
|
37
|
-
Object.entries(schema.errors ?? {}).map(([status, value]) => [
|
|
38
|
-
status,
|
|
39
|
-
schemaPartToReflection(value) as SchemaReflection,
|
|
40
|
-
]),
|
|
41
|
-
)
|
|
42
|
-
const headers = schemaPartToReflection(schema.headers)
|
|
43
|
-
const body = schemaPartToReflection(schema.body)
|
|
44
|
-
const query = schemaPartToReflection(schema.query)
|
|
45
|
-
const params = schemaPartToReflection(schema.params)
|
|
46
|
-
const response = schemaPartToReflection(schema.response)
|
|
47
|
-
const sse = schemaPartToReflection(schema.sse)
|
|
48
|
-
return {
|
|
49
|
-
...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
|
|
50
|
-
...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
|
|
51
|
-
...(headers !== undefined ? { headers } : {}),
|
|
52
|
-
...(body !== undefined ? { body } : {}),
|
|
53
|
-
...(query !== undefined ? { query } : {}),
|
|
54
|
-
...(params !== undefined ? { params } : {}),
|
|
55
|
-
...(response !== undefined ? { response } : {}),
|
|
56
|
-
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
|
57
|
-
...(sse !== undefined ? { sse } : {}),
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Adapt the canonical token-only snapshot to the legacy reflection view used by Markdown helpers. */
|
|
62
|
-
export function reflectedRoutesFromEvidence(evidence: ProjectEvidenceSnapshot): ReflectedRoute[] {
|
|
63
|
-
return evidence.routes.map((route) => {
|
|
64
|
-
const schema = schemaFromEvidence(route.schema)
|
|
65
|
-
return {
|
|
66
|
-
method: route.method,
|
|
67
|
-
path: route.path,
|
|
68
|
-
...(schema !== undefined ? { schema } : {}),
|
|
69
|
-
...(route.assurance !== undefined ? { assurance: route.assurance } : {}),
|
|
70
|
-
...(route.capabilities !== undefined ? { capabilities: route.capabilities } : {}),
|
|
71
|
-
...(route.family === true ? { family: true } : {}),
|
|
72
|
-
...(route.classification !== undefined ? { classification: route.classification } : {}),
|
|
73
|
-
...(route.tool !== undefined ? { tool: route.tool } : {}),
|
|
74
|
-
}
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
26
|
interface JsonSchemaNode {
|
|
79
27
|
readonly type?: unknown
|
|
80
28
|
readonly properties?: Record<string, unknown>
|
package/src/load.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from "node:fs"
|
|
16
16
|
import { isAbsolute, resolve } from "node:path"
|
|
17
|
+
import type { CssLoadingMode } from "@nifrajs/web"
|
|
17
18
|
import { checkPipelineSeparation } from "./pipeline-guard.ts"
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -60,6 +61,10 @@ export interface NifraFramework {
|
|
|
60
61
|
readonly publicDir?: string | false
|
|
61
62
|
/** Client-visible environment prefix (default `"PUBLIC_"`; empty disables exposure). */
|
|
62
63
|
readonly publicEnvPrefix?: string
|
|
64
|
+
/** Vite production CSS output policy. `false` emits one aggregate stylesheet for the whole client. */
|
|
65
|
+
readonly cssCodeSplit?: boolean
|
|
66
|
+
/** Framework-owned SSR stylesheet activation. Deferred mode is intended for aggregate CSS. */
|
|
67
|
+
readonly cssLoading?: CssLoadingMode
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
/**
|
package/src/mcp-context.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { realpathSync } from "node:fs"
|
|
9
9
|
import { readFile } from "node:fs/promises"
|
|
10
|
-
import { basename, isAbsolute, relative, resolve
|
|
10
|
+
import { basename, isAbsolute, relative, resolve } from "node:path"
|
|
11
11
|
import type { LoadedApp } from "./load.ts"
|
|
12
12
|
import { createCachedAppLoader } from "./mcp-exec.ts"
|
|
13
|
+
import { resolveMcpProjectPath } from "./mcp-path.ts"
|
|
13
14
|
import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
|
|
14
15
|
|
|
15
16
|
function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
|
|
@@ -31,11 +32,8 @@ async function readProjectFile(
|
|
|
31
32
|
relativeFile: string,
|
|
32
33
|
maxChars: number,
|
|
33
34
|
): Promise<string> {
|
|
34
|
-
const target =
|
|
35
|
-
|
|
36
|
-
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
37
|
-
throw new Error(`refusing to read outside project root: ${relativeFile}`)
|
|
38
|
-
}
|
|
35
|
+
const target = resolveMcpProjectPath(cwd, relativeFile)
|
|
36
|
+
if (target === null) throw new Error(`refusing to read outside project root: ${relativeFile}`)
|
|
39
37
|
try {
|
|
40
38
|
const text = await readFile(target, "utf8")
|
|
41
39
|
return text.length <= maxChars
|
package/src/mcp-exec.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { stat } from "node:fs/promises"
|
|
10
|
-
import { resolve
|
|
10
|
+
import { resolve } from "node:path"
|
|
11
11
|
import { fileURLToPath } from "node:url"
|
|
12
12
|
import { Glob } from "bun"
|
|
13
13
|
import {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "./mcp-context.ts"
|
|
31
31
|
import { docsTools } from "./mcp-docs-tools.ts"
|
|
32
32
|
import {
|
|
33
|
+
CHILD_INPUT_MAX_BYTES,
|
|
33
34
|
CHILD_OUTPUT_MAX_BYTES,
|
|
34
35
|
CHILD_TIMEOUT_MS,
|
|
35
36
|
LOCAL_TOOL_FETCH_TIMEOUT_MS,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
timeoutMessage,
|
|
40
41
|
validateLocalPort,
|
|
41
42
|
} from "./mcp-io.ts"
|
|
43
|
+
import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.ts"
|
|
42
44
|
import type { McpTool, McpToolContext } from "./mcp-protocol.ts"
|
|
43
45
|
import { loadTypesCorpus } from "./types-search.ts"
|
|
44
46
|
|
|
@@ -54,6 +56,7 @@ export function toMcpTool(
|
|
|
54
56
|
const entry = "run" in source ? undefined : source
|
|
55
57
|
const { cwd } = options
|
|
56
58
|
const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
|
|
59
|
+
const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"] as const
|
|
57
60
|
return {
|
|
58
61
|
name: commandMcpName(entry?.name ?? spec.name),
|
|
59
62
|
description: entry?.summary ?? spec.summary,
|
|
@@ -64,14 +67,28 @@ export function toMcpTool(
|
|
|
64
67
|
if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
|
|
65
68
|
const target = resolveProjectDir(cwd, dir as string | undefined)
|
|
66
69
|
if (target === null) return dirError(dir as string | undefined)
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
for (const field of pathFields) {
|
|
71
|
+
const value = raw[field]
|
|
72
|
+
if (typeof value !== "string") continue
|
|
73
|
+
const safe = resolveMcpProjectPath(target, value)
|
|
74
|
+
if (safe === null)
|
|
75
|
+
return JSON.stringify({ ok: false, error: mcpProjectPathError(field, value) }, null, 2)
|
|
76
|
+
raw[field] = safe
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(raw.files)) {
|
|
79
|
+
const safeFiles: string[] = []
|
|
80
|
+
for (const value of raw.files) {
|
|
81
|
+
if (typeof value !== "string") continue
|
|
82
|
+
const safe = resolveMcpProjectPath(target, value)
|
|
83
|
+
if (safe === null)
|
|
84
|
+
return JSON.stringify(
|
|
85
|
+
{ ok: false, error: mcpProjectPathError("files", value) },
|
|
86
|
+
null,
|
|
87
|
+
2,
|
|
88
|
+
)
|
|
89
|
+
safeFiles.push(safe)
|
|
90
|
+
}
|
|
91
|
+
raw.files = safeFiles
|
|
75
92
|
}
|
|
76
93
|
delete raw.dir
|
|
77
94
|
let input: unknown
|
|
@@ -194,10 +211,21 @@ export function projectTools(
|
|
|
194
211
|
if (baseline !== undefined && baseline.trim() === "") {
|
|
195
212
|
return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
|
|
196
213
|
}
|
|
214
|
+
const safeBaseline =
|
|
215
|
+
baseline === undefined ? undefined : resolveMcpProjectPath(target, baseline)
|
|
216
|
+
if (baseline !== undefined && safeBaseline === null) {
|
|
217
|
+
return JSON.stringify(
|
|
218
|
+
{ ok: false, error: mcpProjectPathError("baseline", baseline) },
|
|
219
|
+
null,
|
|
220
|
+
2,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
197
223
|
try {
|
|
198
224
|
return JSON.stringify(
|
|
199
225
|
await collectContractProof(target, {
|
|
200
|
-
...(
|
|
226
|
+
...(safeBaseline === undefined || safeBaseline === null
|
|
227
|
+
? {}
|
|
228
|
+
: { baselinePath: safeBaseline }),
|
|
201
229
|
...(raw.check === undefined ? {} : { check: raw.check === true }),
|
|
202
230
|
}),
|
|
203
231
|
null,
|
|
@@ -641,6 +669,15 @@ export async function spawnChild(
|
|
|
641
669
|
signal?: AbortSignal,
|
|
642
670
|
): Promise<string> {
|
|
643
671
|
if (signal?.aborted) return `${label} cancelled before it started.`
|
|
672
|
+
let encodedInput: string
|
|
673
|
+
try {
|
|
674
|
+
encodedInput = JSON.stringify(input)
|
|
675
|
+
} catch {
|
|
676
|
+
return `${label} input is not serializable.`
|
|
677
|
+
}
|
|
678
|
+
if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
|
|
679
|
+
return `${label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes.`
|
|
680
|
+
if (signal?.aborted) return `${label} cancelled${cancellationSuffix(signal)}.`
|
|
644
681
|
const proc = Bun.spawn(["bun", childPath(child), cwd], {
|
|
645
682
|
stdin: "pipe",
|
|
646
683
|
stdout: "pipe",
|
|
@@ -661,8 +698,11 @@ export async function spawnChild(
|
|
|
661
698
|
}, CHILD_TIMEOUT_MS)
|
|
662
699
|
let outputExceeded = false
|
|
663
700
|
try {
|
|
664
|
-
|
|
665
|
-
|
|
701
|
+
if (signal?.aborted) kill()
|
|
702
|
+
else {
|
|
703
|
+
proc.stdin.write(encodedInput)
|
|
704
|
+
await proc.stdin.end()
|
|
705
|
+
}
|
|
666
706
|
const [out, err] = await Promise.all([
|
|
667
707
|
readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
|
|
668
708
|
outputExceeded = true
|
|
@@ -675,7 +715,7 @@ export async function spawnChild(
|
|
|
675
715
|
])
|
|
676
716
|
await proc.exited
|
|
677
717
|
if (signal?.aborted) {
|
|
678
|
-
const reason =
|
|
718
|
+
const reason = cancellationSuffix(signal)
|
|
679
719
|
return `${label} cancelled${reason}.`
|
|
680
720
|
}
|
|
681
721
|
if (outputExceeded) {
|
|
@@ -696,6 +736,7 @@ const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
|
|
|
696
736
|
const WARM_RUN_IGNORED =
|
|
697
737
|
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
698
738
|
const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
|
|
739
|
+
const MAX_WARM_PENDING = 64
|
|
699
740
|
|
|
700
741
|
async function warmRunFingerprint(cwd: string): Promise<string> {
|
|
701
742
|
const parts: string[] = []
|
|
@@ -743,6 +784,7 @@ export class WarmWorker {
|
|
|
743
784
|
}
|
|
744
785
|
>()
|
|
745
786
|
private stdoutBuffer = ""
|
|
787
|
+
private stdoutBufferBytes = 0
|
|
746
788
|
private stderrBuffer = ""
|
|
747
789
|
private nextId = 0
|
|
748
790
|
private closed = false
|
|
@@ -780,7 +822,18 @@ export class WarmWorker {
|
|
|
780
822
|
async request(input: unknown, signal?: AbortSignal): Promise<string> {
|
|
781
823
|
if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
|
|
782
824
|
if (signal?.aborted) return `${this.label} cancelled before it started.`
|
|
825
|
+
if (this.pending.size >= MAX_WARM_PENDING)
|
|
826
|
+
throw new Error(`warm ${this.label} worker request limit reached`)
|
|
783
827
|
const id = ++this.nextId
|
|
828
|
+
let encodedInput: string
|
|
829
|
+
try {
|
|
830
|
+
encodedInput = JSON.stringify({ id, input })
|
|
831
|
+
} catch {
|
|
832
|
+
throw new Error(`warm ${this.label} input is not serializable`)
|
|
833
|
+
}
|
|
834
|
+
if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
|
|
835
|
+
throw new Error(`warm ${this.label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes`)
|
|
836
|
+
if (signal?.aborted) return `${this.label} cancelled${cancellationSuffix(signal)}.`
|
|
784
837
|
return new Promise((resolve, reject) => {
|
|
785
838
|
const abort = (): void => {
|
|
786
839
|
// Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
|
|
@@ -789,7 +842,8 @@ export class WarmWorker {
|
|
|
789
842
|
// cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
|
|
790
843
|
this.pending.delete(id)
|
|
791
844
|
clearTimeout(timer)
|
|
792
|
-
|
|
845
|
+
signal?.removeEventListener("abort", abort)
|
|
846
|
+
const reason = cancellationSuffix(signal)
|
|
793
847
|
resolve(`${this.label} cancelled${reason}.`)
|
|
794
848
|
}
|
|
795
849
|
// Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
|
|
@@ -807,8 +861,15 @@ export class WarmWorker {
|
|
|
807
861
|
}
|
|
808
862
|
signal?.addEventListener("abort", abort, { once: true })
|
|
809
863
|
this.pending.set(id, { resolve, reject, cleanup })
|
|
864
|
+
// JSON serialization above can run user-defined `toJSON` hooks. Recheck after installing the
|
|
865
|
+
// listener and pending entry so an abort during that synchronous work cannot enqueue a request
|
|
866
|
+
// after cancellation, and so an already-aborted signal does not retain the listener.
|
|
867
|
+
if (signal?.aborted) {
|
|
868
|
+
abort()
|
|
869
|
+
return
|
|
870
|
+
}
|
|
810
871
|
try {
|
|
811
|
-
this.proc.stdin.write(`${
|
|
872
|
+
this.proc.stdin.write(`${encodedInput}\n`)
|
|
812
873
|
} catch (err) {
|
|
813
874
|
this.pending.delete(id)
|
|
814
875
|
cleanup()
|
|
@@ -831,8 +892,10 @@ export class WarmWorker {
|
|
|
831
892
|
this.stop()
|
|
832
893
|
return
|
|
833
894
|
}
|
|
834
|
-
|
|
835
|
-
|
|
895
|
+
const text = decoder.decode(value, { stream: true })
|
|
896
|
+
this.stdoutBuffer += text
|
|
897
|
+
this.stdoutBufferBytes += new TextEncoder().encode(text).byteLength
|
|
898
|
+
if (this.stdoutBufferBytes > CHILD_OUTPUT_MAX_BYTES) {
|
|
836
899
|
this.stderrBuffer = boundedAppend(
|
|
837
900
|
this.stderrBuffer,
|
|
838
901
|
`warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
|
|
@@ -844,6 +907,7 @@ export class WarmWorker {
|
|
|
844
907
|
while (nl !== -1) {
|
|
845
908
|
const line = this.stdoutBuffer.slice(0, nl).trim()
|
|
846
909
|
this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
|
|
910
|
+
this.stdoutBufferBytes = new TextEncoder().encode(this.stdoutBuffer).byteLength
|
|
847
911
|
nl = this.stdoutBuffer.indexOf("\n")
|
|
848
912
|
if (line !== "") this.handleLine(line)
|
|
849
913
|
}
|
|
@@ -861,25 +925,69 @@ export class WarmWorker {
|
|
|
861
925
|
}
|
|
862
926
|
|
|
863
927
|
private handleLine(line: string): void {
|
|
864
|
-
let message:
|
|
928
|
+
let message: unknown
|
|
865
929
|
try {
|
|
866
|
-
message = JSON.parse(line) as
|
|
930
|
+
message = JSON.parse(line) as unknown
|
|
867
931
|
} catch {
|
|
868
932
|
this.stderrBuffer = boundedAppend(
|
|
869
933
|
this.stderrBuffer,
|
|
870
934
|
`invalid warm ${this.label} worker line: ${line}\n`,
|
|
871
935
|
)
|
|
936
|
+
this.stop()
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
if (
|
|
940
|
+
message === null ||
|
|
941
|
+
typeof message !== "object" ||
|
|
942
|
+
Array.isArray(message) ||
|
|
943
|
+
typeof (message as { id?: unknown }).id !== "number" ||
|
|
944
|
+
!Number.isSafeInteger((message as { id: number }).id)
|
|
945
|
+
) {
|
|
946
|
+
this.stderrBuffer = boundedAppend(
|
|
947
|
+
this.stderrBuffer,
|
|
948
|
+
`invalid warm ${this.label} worker response envelope\n`,
|
|
949
|
+
)
|
|
950
|
+
this.stop()
|
|
872
951
|
return
|
|
873
952
|
}
|
|
874
|
-
|
|
875
|
-
const pending = this.pending.get(
|
|
953
|
+
const response = message as { id: number; output?: unknown }
|
|
954
|
+
const pending = this.pending.get(response.id)
|
|
876
955
|
if (pending === undefined) return
|
|
877
|
-
|
|
956
|
+
if (!Object.hasOwn(response, "output")) {
|
|
957
|
+
this.pending.delete(response.id)
|
|
958
|
+
pending.cleanup()
|
|
959
|
+
pending.reject(new Error(`warm ${this.label} worker response is malformed`))
|
|
960
|
+
this.stop()
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
let output: string
|
|
964
|
+
try {
|
|
965
|
+
output = JSON.stringify(response.output, null, 2)
|
|
966
|
+
} catch {
|
|
967
|
+
this.pending.delete(response.id)
|
|
968
|
+
pending.cleanup()
|
|
969
|
+
pending.reject(new Error(`warm ${this.label} worker response is not serializable`))
|
|
970
|
+
this.stop()
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
if (new TextEncoder().encode(output).byteLength > CHILD_OUTPUT_MAX_BYTES) {
|
|
974
|
+
this.pending.delete(response.id)
|
|
975
|
+
pending.cleanup()
|
|
976
|
+
pending.reject(new Error(`warm ${this.label} worker response exceeded the size limit`))
|
|
977
|
+
this.stop()
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
this.pending.delete(response.id)
|
|
878
981
|
pending.cleanup()
|
|
879
|
-
pending.resolve(
|
|
982
|
+
pending.resolve(output)
|
|
880
983
|
}
|
|
881
984
|
}
|
|
882
985
|
|
|
986
|
+
function cancellationSuffix(signal: AbortSignal | undefined): string {
|
|
987
|
+
const reason = signal?.reason
|
|
988
|
+
return typeof reason === "string" && reason.length > 0 ? `: ${reason.slice(0, 512)}` : ""
|
|
989
|
+
}
|
|
990
|
+
|
|
883
991
|
/** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
|
|
884
992
|
* subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
|
|
885
993
|
* `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
|
|
@@ -892,17 +1000,21 @@ export function createWarmHandler(
|
|
|
892
1000
|
let worker: WarmWorker | undefined
|
|
893
1001
|
return async (input, signal) => {
|
|
894
1002
|
const fingerprint = await warmRunFingerprint(cwd)
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1003
|
+
let current = worker
|
|
1004
|
+
if (current === undefined || current.fingerprint !== fingerprint) {
|
|
1005
|
+
current?.stop()
|
|
1006
|
+
current = new WarmWorker(child, cwd, fingerprint, label)
|
|
1007
|
+
worker = current
|
|
898
1008
|
}
|
|
899
1009
|
try {
|
|
900
|
-
return await
|
|
1010
|
+
return await current.request(input, signal)
|
|
901
1011
|
} catch {
|
|
902
|
-
worker.
|
|
903
|
-
|
|
1012
|
+
// A source change can replace the shared worker while this request is in flight. Only clear
|
|
1013
|
+
// the slot if this request still owns it; an old request must never tear down the replacement.
|
|
1014
|
+
current.stop()
|
|
1015
|
+
if (worker === current) worker = undefined
|
|
904
1016
|
if (signal?.aborted) {
|
|
905
|
-
const reason =
|
|
1017
|
+
const reason = cancellationSuffix(signal)
|
|
906
1018
|
return `${label} cancelled${reason}.`
|
|
907
1019
|
}
|
|
908
1020
|
return spawnChild(child, cwd, input, label, signal)
|
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 = "3.
|
|
32
|
+
const VERSION = "3.5.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 =>
|
|
@@ -69,6 +69,9 @@ export function handleMcpHttp(request: Request): Promise<Response> {
|
|
|
69
69
|
instructions:
|
|
70
70
|
"nifra's own documentation, runnable examples, and API types - for building with the nifra framework. Call nifra_docs / nifra_example / nifra_types / nifra_learn to learn it without a local checkout.",
|
|
71
71
|
},
|
|
72
|
+
// This is the intentionally public, secret-free docs server. Other MCP mounts use the
|
|
73
|
+
// transport's same-origin default unless they explicitly opt into public cross-origin access.
|
|
74
|
+
allowAnyOrigin: true,
|
|
72
75
|
})
|
|
73
76
|
}
|
|
74
77
|
|
package/src/mcp-io.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
export const CHILD_TIMEOUT_MS = 30_000
|
|
9
9
|
/** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
|
|
10
10
|
export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
|
|
11
|
+
/** Maximum serialized request sent to a project subprocess or warm worker. */
|
|
12
|
+
export const CHILD_INPUT_MAX_BYTES = 4 * 1024 * 1024
|
|
11
13
|
|
|
12
14
|
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
13
15
|
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
@@ -81,6 +83,77 @@ export interface BoundedOutput {
|
|
|
81
83
|
readonly truncated: boolean
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
export type BoundedLine =
|
|
87
|
+
| { readonly kind: "line"; readonly text: string }
|
|
88
|
+
| { readonly kind: "too-large" }
|
|
89
|
+
|
|
90
|
+
function decodeLine(chunks: readonly Uint8Array[], totalBytes: number): string {
|
|
91
|
+
const bytes = new Uint8Array(totalBytes)
|
|
92
|
+
let offset = 0
|
|
93
|
+
for (const chunk of chunks) {
|
|
94
|
+
bytes.set(chunk, offset)
|
|
95
|
+
offset += chunk.byteLength
|
|
96
|
+
}
|
|
97
|
+
return new TextDecoder().decode(bytes)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read newline-delimited input without retaining a line larger than the configured byte budget. */
|
|
101
|
+
export async function* readBoundedLines(
|
|
102
|
+
stream: ReadableStream<Uint8Array>,
|
|
103
|
+
maxBytes = CHILD_INPUT_MAX_BYTES,
|
|
104
|
+
): AsyncGenerator<BoundedLine> {
|
|
105
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
106
|
+
throw new RangeError("maxBytes must be a positive safe integer")
|
|
107
|
+
const chunks: Uint8Array[] = []
|
|
108
|
+
let totalBytes = 0
|
|
109
|
+
let tooLarge = false
|
|
110
|
+
for await (const raw of stream) {
|
|
111
|
+
const value = raw as Uint8Array
|
|
112
|
+
let start = 0
|
|
113
|
+
while (start < value.byteLength) {
|
|
114
|
+
const newline = value.indexOf(0x0a, start)
|
|
115
|
+
const end = newline === -1 ? value.byteLength : newline
|
|
116
|
+
const segment = value.subarray(start, end)
|
|
117
|
+
if (!tooLarge) {
|
|
118
|
+
if (totalBytes + segment.byteLength > maxBytes) {
|
|
119
|
+
tooLarge = true
|
|
120
|
+
chunks.length = 0
|
|
121
|
+
totalBytes = 0
|
|
122
|
+
} else if (segment.byteLength > 0) {
|
|
123
|
+
chunks.push(segment)
|
|
124
|
+
totalBytes += segment.byteLength
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (newline === -1) break
|
|
128
|
+
if (tooLarge) yield { kind: "too-large" }
|
|
129
|
+
else yield { kind: "line", text: decodeLine(chunks, totalBytes) }
|
|
130
|
+
chunks.length = 0
|
|
131
|
+
totalBytes = 0
|
|
132
|
+
tooLarge = false
|
|
133
|
+
start = newline + 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (tooLarge) yield { kind: "too-large" }
|
|
137
|
+
else if (totalBytes > 0) yield { kind: "line", text: decodeLine(chunks, totalBytes) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Serialize a JSON value only when its UTF-8 representation fits within the byte budget. */
|
|
141
|
+
export function serializeBoundedJson(
|
|
142
|
+
value: unknown,
|
|
143
|
+
maxBytes: number,
|
|
144
|
+
space?: string | number,
|
|
145
|
+
): string | undefined {
|
|
146
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return undefined
|
|
147
|
+
let text: string | undefined
|
|
148
|
+
try {
|
|
149
|
+
text = JSON.stringify(value, null, space)
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
if (text === undefined || new TextEncoder().encode(text).byteLength > maxBytes) return undefined
|
|
154
|
+
return text
|
|
155
|
+
}
|
|
156
|
+
|
|
84
157
|
/** Read child output incrementally and cancel as soon as the byte budget is crossed. */
|
|
85
158
|
export async function readBoundedStream(
|
|
86
159
|
stream: ReadableStream<Uint8Array>,
|
package/src/mcp-path.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import { basename, isAbsolute, relative, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
|
|
6
|
+
*
|
|
7
|
+
* MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
|
|
8
|
+
* capability boundary. The nearest-existing-ancestor walk also supports output files that do not
|
|
9
|
+
* exist yet while still canonicalizing every existing path component.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveMcpProjectPath(root: string, value: string): string | null {
|
|
12
|
+
if (value.length === 0 || isAbsolute(value)) return null
|
|
13
|
+
|
|
14
|
+
const lexicalRoot = resolve(root)
|
|
15
|
+
const target = resolve(lexicalRoot, value)
|
|
16
|
+
const lexicalRelative = relative(lexicalRoot, target)
|
|
17
|
+
if (lexicalRelative.startsWith("..") || isAbsolute(lexicalRelative)) return null
|
|
18
|
+
|
|
19
|
+
let canonicalRoot: string
|
|
20
|
+
try {
|
|
21
|
+
canonicalRoot = realpathSync(lexicalRoot)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let probe = target
|
|
27
|
+
const missing: string[] = []
|
|
28
|
+
while (true) {
|
|
29
|
+
try {
|
|
30
|
+
const canonicalProbe = realpathSync(probe)
|
|
31
|
+
const canonicalRelative = relative(canonicalRoot, canonicalProbe)
|
|
32
|
+
if (canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) return null
|
|
33
|
+
|
|
34
|
+
const candidate = resolve(canonicalProbe, ...missing.reverse())
|
|
35
|
+
const candidateRelative = relative(canonicalRoot, candidate)
|
|
36
|
+
return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT"))
|
|
39
|
+
return null
|
|
40
|
+
const parent = resolve(probe, "..")
|
|
41
|
+
if (parent === probe) return null
|
|
42
|
+
missing.push(basename(probe))
|
|
43
|
+
probe = parent
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mcpProjectPathError(label: string, value: string): string {
|
|
49
|
+
return `${label} must be a relative path inside the selected project directory: ${value}`
|
|
50
|
+
}
|
package/src/mcp-reflect.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* about stdio, HTTP, root trust, or project loading, so the reflection seam is independently testable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { reflectedRoutesFromEvidence, snapshotProjectEvidence } from "@nifrajs/core/evidence"
|
|
9
|
+
import type { ReflectedRoute } from "@nifrajs/core/reflection"
|
|
9
10
|
import type {
|
|
10
11
|
McpPrompt,
|
|
11
12
|
McpPromptMessage,
|
|
@@ -24,7 +25,7 @@ type ToolBackend = {
|
|
|
24
25
|
export function extractBackendTools(backend: unknown): McpTool[] {
|
|
25
26
|
const b = backend as ToolBackend | null
|
|
26
27
|
if (!b || typeof b.routes !== "function") return []
|
|
27
|
-
const routes =
|
|
28
|
+
const routes = reflectedRoutesFromEvidence(snapshotProjectEvidence(b))
|
|
28
29
|
|
|
29
30
|
return routes
|
|
30
31
|
.filter(
|