@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/mcp-render.ts
CHANGED
|
@@ -15,6 +15,13 @@ import { type AppLike, runApp } from "@nifrajs/runner"
|
|
|
15
15
|
import { createWebApp, type RenderAdapter } from "@nifrajs/web"
|
|
16
16
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
17
|
import { loadApp, resolvePlugins } from "./load.ts"
|
|
18
|
+
import {
|
|
19
|
+
CHILD_INPUT_MAX_BYTES,
|
|
20
|
+
CHILD_OUTPUT_MAX_BYTES,
|
|
21
|
+
readBoundedLines,
|
|
22
|
+
readBoundedStream,
|
|
23
|
+
serializeBoundedJson,
|
|
24
|
+
} from "./mcp-io.ts"
|
|
18
25
|
|
|
19
26
|
const errString = (err: unknown): string =>
|
|
20
27
|
err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
@@ -89,6 +96,12 @@ function redirectConsoleToStderr(): void {
|
|
|
89
96
|
console.error = (...args: unknown[]) => write("error", args)
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
function safeResponseId(value: unknown): number | string | null {
|
|
100
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return value
|
|
101
|
+
if (typeof value === "string" && value.length <= 128) return value
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
/**
|
|
93
106
|
* Warm-worker loop (`--worker`): build the web app ONCE, then SSR each `{ id, input: { requests } }` line
|
|
94
107
|
* against that hot app and reply `{ id, output }`. The parent (`mcp.ts`'s warm-render handler) spawns this,
|
|
@@ -100,43 +113,51 @@ async function runWorker(cwd: string): Promise<void> {
|
|
|
100
113
|
// Build once; the same `{ error }` is returned for every request if the build failed, so an agent sees
|
|
101
114
|
// the actionable message and the parent restarts the worker on the next file change.
|
|
102
115
|
const built = await buildWebApp(cwd)
|
|
103
|
-
const decoder = new TextDecoder()
|
|
104
|
-
let buffer = ""
|
|
105
116
|
const send = (id: unknown, output: unknown): void => {
|
|
106
|
-
|
|
117
|
+
const responseId = safeResponseId(id)
|
|
118
|
+
const encoded = serializeBoundedJson({ id: responseId, output }, CHILD_OUTPUT_MAX_BYTES - 1)
|
|
119
|
+
process.stdout.write(
|
|
120
|
+
`${encoded ?? JSON.stringify({ id: responseId, output: { error: "worker output exceeded the size limit" } })}\n`,
|
|
121
|
+
)
|
|
107
122
|
}
|
|
108
|
-
for await (const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (line === "") continue
|
|
123
|
+
for await (const item of readBoundedLines(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)) {
|
|
124
|
+
if (item.kind === "too-large") {
|
|
125
|
+
send(null, { error: `worker input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` })
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const line = item.text.trim()
|
|
129
|
+
if (line === "") continue
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
let message: unknown
|
|
132
|
+
try {
|
|
133
|
+
message = JSON.parse(line) as unknown
|
|
134
|
+
} catch {
|
|
135
|
+
send(null, { error: "invalid worker input: expected JSON line" })
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const record =
|
|
139
|
+
message !== null && typeof message === "object" && !Array.isArray(message)
|
|
140
|
+
? (message as RenderWorkerMessage)
|
|
141
|
+
: undefined
|
|
142
|
+
const input =
|
|
143
|
+
record?.input !== null && typeof record?.input === "object" && !Array.isArray(record.input)
|
|
144
|
+
? record.input
|
|
145
|
+
: undefined
|
|
146
|
+
const requests = input?.requests
|
|
147
|
+
if (!Array.isArray(requests)) {
|
|
148
|
+
send(record?.id, { error: "expected { requests: [...] }" })
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if ("error" in built) {
|
|
152
|
+
send(record?.id, built)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
send(record?.id, {
|
|
157
|
+
results: await runApp(built.app, requests as Parameters<typeof runApp>[1]),
|
|
158
|
+
})
|
|
159
|
+
} catch (err) {
|
|
160
|
+
send(record?.id, { error: errString(err) })
|
|
140
161
|
}
|
|
141
162
|
}
|
|
142
163
|
}
|
|
@@ -163,13 +184,22 @@ if (import.meta.main) {
|
|
|
163
184
|
process.exit(0)
|
|
164
185
|
}
|
|
165
186
|
let output: unknown
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
output =
|
|
169
|
-
}
|
|
170
|
-
|
|
187
|
+
const input = await readBoundedStream(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)
|
|
188
|
+
if (input.truncated) {
|
|
189
|
+
output = { error: `input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` }
|
|
190
|
+
} else {
|
|
191
|
+
try {
|
|
192
|
+
const { requests } = JSON.parse(input.text) as { requests: unknown }
|
|
193
|
+
output = await renderPages(cwd, requests)
|
|
194
|
+
} catch {
|
|
195
|
+
output = { error: "invalid input: expected JSON { requests: [...] }" }
|
|
196
|
+
}
|
|
171
197
|
}
|
|
172
|
-
await Bun.write(
|
|
198
|
+
await Bun.write(
|
|
199
|
+
Bun.stdout,
|
|
200
|
+
serializeBoundedJson(output, CHILD_OUTPUT_MAX_BYTES, 2) ??
|
|
201
|
+
JSON.stringify({ error: `output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes` }),
|
|
202
|
+
)
|
|
173
203
|
// Exit explicitly, exactly as the `--worker` branch above already does.
|
|
174
204
|
//
|
|
175
205
|
// Loading the app runs its module side effects, and a real app opens things: a database pool, a
|
package/src/mcp-root.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface McpRootState {
|
|
|
33
33
|
readonly clientRoots: readonly string[] | null
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const MAX_CLIENT_ROOTS = 64
|
|
37
|
+
const MAX_ROOT_URI_LENGTH = 16_384
|
|
38
|
+
const MAX_ROOT_PATH_LENGTH = 4_096
|
|
39
|
+
|
|
36
40
|
/** A directory is a nifra project when its `package.json` depends on any `@nifrajs/*` package, or it
|
|
37
41
|
* is a `nifra.config.ts` monorepo root. Backend-only projects (no web config, no `routes/`) count -
|
|
38
42
|
* the marker is the dependency, not the app shape. */
|
|
@@ -98,9 +102,12 @@ export function pathsFromRootsResult(result: unknown): string[] {
|
|
|
98
102
|
const roots = (result as { roots?: unknown }).roots
|
|
99
103
|
if (!Array.isArray(roots)) return []
|
|
100
104
|
const paths: string[] = []
|
|
105
|
+
const seen = new Set<string>()
|
|
101
106
|
for (const entry of roots) {
|
|
107
|
+
if (paths.length >= MAX_CLIENT_ROOTS) break
|
|
102
108
|
const uri = (entry as { uri?: unknown })?.uri
|
|
103
|
-
if (typeof uri !== "string" || !uri.startsWith("file://"))
|
|
109
|
+
if (typeof uri !== "string" || uri.length > MAX_ROOT_URI_LENGTH || !uri.startsWith("file://"))
|
|
110
|
+
continue
|
|
104
111
|
try {
|
|
105
112
|
const parsed = new URL(uri)
|
|
106
113
|
// A remote file host is a UNC path on Windows, not a local workspace root. MCP roots are
|
|
@@ -111,7 +118,11 @@ export function pathsFromRootsResult(result: unknown): string[] {
|
|
|
111
118
|
(parsed.hostname !== "" && parsed.hostname !== "localhost")
|
|
112
119
|
)
|
|
113
120
|
continue
|
|
114
|
-
|
|
121
|
+
const path = fileURLToPath(parsed)
|
|
122
|
+
if (path.length === 0 || path.length > MAX_ROOT_PATH_LENGTH || path.includes("\0")) continue
|
|
123
|
+
if (seen.has(path)) continue
|
|
124
|
+
seen.add(path)
|
|
125
|
+
paths.push(path)
|
|
115
126
|
} catch {
|
|
116
127
|
// Malformed URI - skip.
|
|
117
128
|
}
|
package/src/mcp-run.ts
CHANGED
|
@@ -9,6 +9,13 @@ import { existsSync, realpathSync } from "node:fs"
|
|
|
9
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
|
+
import {
|
|
13
|
+
CHILD_INPUT_MAX_BYTES,
|
|
14
|
+
CHILD_OUTPUT_MAX_BYTES,
|
|
15
|
+
readBoundedLines,
|
|
16
|
+
readBoundedStream,
|
|
17
|
+
serializeBoundedJson,
|
|
18
|
+
} from "./mcp-io.ts"
|
|
12
19
|
|
|
13
20
|
const ENTRY_CANDIDATES = ["backend.ts", "app.ts", "src/backend.ts", "src/app.ts"]
|
|
14
21
|
|
|
@@ -100,54 +107,79 @@ function redirectConsoleToStderr(): void {
|
|
|
100
107
|
console.error = (...args: unknown[]) => write("error", args)
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
function safeResponseId(value: unknown): number | string | null {
|
|
111
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return value
|
|
112
|
+
if (typeof value === "string" && value.length <= 128) return value
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
103
116
|
async function runWorker(cwd: string): Promise<void> {
|
|
104
117
|
redirectConsoleToStderr()
|
|
105
118
|
const apps = new Map<string, Promise<{ app: AppLike } | { error: string }>>()
|
|
106
|
-
const decoder = new TextDecoder()
|
|
107
|
-
let buffer = ""
|
|
108
119
|
const send = (id: unknown, output: unknown): void => {
|
|
109
|
-
|
|
120
|
+
const responseId = safeResponseId(id)
|
|
121
|
+
const encoded = serializeBoundedJson({ id: responseId, output }, CHILD_OUTPUT_MAX_BYTES - 1)
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
`${encoded ?? JSON.stringify({ id: responseId, output: { error: "worker output exceeded the size limit" } })}\n`,
|
|
124
|
+
)
|
|
110
125
|
}
|
|
111
|
-
for await (const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (line === "") continue
|
|
126
|
+
for await (const item of readBoundedLines(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)) {
|
|
127
|
+
if (item.kind === "too-large") {
|
|
128
|
+
send(null, { error: `worker input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` })
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const line = item.text.trim()
|
|
132
|
+
if (line === "") continue
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
134
|
+
let message: unknown
|
|
135
|
+
try {
|
|
136
|
+
message = JSON.parse(line) as unknown
|
|
137
|
+
} catch {
|
|
138
|
+
send(null, { error: "invalid worker input: expected JSON line" })
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const record =
|
|
142
|
+
message !== null && typeof message === "object" && !Array.isArray(message)
|
|
143
|
+
? (message as WorkerMessage)
|
|
144
|
+
: undefined
|
|
145
|
+
const input =
|
|
146
|
+
record?.input !== null && typeof record?.input === "object" && !Array.isArray(record.input)
|
|
147
|
+
? record.input
|
|
148
|
+
: undefined
|
|
149
|
+
const id = record?.id
|
|
150
|
+
const requests = input?.requests
|
|
151
|
+
const entry = input?.entry
|
|
152
|
+
if (!Array.isArray(requests)) {
|
|
153
|
+
send(id, { error: "expected { requests: [...] }" })
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (entry !== undefined && typeof entry !== "string") {
|
|
157
|
+
send(id, { error: "entry must be a string" })
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
const key = entry ?? ""
|
|
161
|
+
let loaded = apps.get(key)
|
|
162
|
+
if (loaded === undefined) {
|
|
163
|
+
loaded = loadBackend(cwd, entry)
|
|
164
|
+
apps.set(key, loaded)
|
|
165
|
+
}
|
|
166
|
+
let app: Awaited<typeof loaded>
|
|
167
|
+
try {
|
|
168
|
+
app = await loaded
|
|
169
|
+
} catch (err) {
|
|
170
|
+
send(id, { error: errString(err) })
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if ("error" in app) {
|
|
174
|
+
send(id, app)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
send(id, {
|
|
179
|
+
results: await runApp(app.app, requests as Parameters<typeof runApp>[1]),
|
|
180
|
+
})
|
|
181
|
+
} catch (err) {
|
|
182
|
+
send(id, { error: errString(err) })
|
|
151
183
|
}
|
|
152
184
|
}
|
|
153
185
|
}
|
|
@@ -161,14 +193,22 @@ if (import.meta.main) {
|
|
|
161
193
|
process.exit(0)
|
|
162
194
|
}
|
|
163
195
|
let output: unknown
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
const input = await readBoundedStream(Bun.stdin.stream(), CHILD_INPUT_MAX_BYTES)
|
|
197
|
+
if (input.truncated) {
|
|
198
|
+
output = { error: `input exceeded ${CHILD_INPUT_MAX_BYTES} bytes` }
|
|
199
|
+
} else {
|
|
200
|
+
try {
|
|
201
|
+
const { requests, entry } = JSON.parse(input.text) as {
|
|
202
|
+
requests: unknown
|
|
203
|
+
entry?: string
|
|
204
|
+
}
|
|
205
|
+
output = await runBackend(cwd, requests, entry)
|
|
206
|
+
} catch {
|
|
207
|
+
output = { error: "invalid input: expected JSON { requests: [...] }" }
|
|
168
208
|
}
|
|
169
|
-
output = await runBackend(cwd, requests, entry)
|
|
170
|
-
} catch {
|
|
171
|
-
output = { error: "invalid input: expected JSON { requests: [...] }" }
|
|
172
209
|
}
|
|
173
|
-
process.stdout.write(
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
serializeBoundedJson(output, CHILD_OUTPUT_MAX_BYTES, 2) ??
|
|
212
|
+
JSON.stringify({ error: `output exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes` }),
|
|
213
|
+
)
|
|
174
214
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -45,6 +45,8 @@ import { docsTools } from "./mcp-docs-tools.ts"
|
|
|
45
45
|
import {
|
|
46
46
|
createMcpProtocolState,
|
|
47
47
|
handleRpc,
|
|
48
|
+
isJsonRpcRequest,
|
|
49
|
+
isJsonRpcResponse,
|
|
48
50
|
type JsonRpcNotification,
|
|
49
51
|
type JsonRpcRequest,
|
|
50
52
|
type JsonRpcResponse,
|
|
@@ -80,15 +82,18 @@ import {
|
|
|
80
82
|
type ToolingDrift,
|
|
81
83
|
} from "./mcp-root.ts"
|
|
82
84
|
|
|
83
|
-
export type { BoundedOutput } from "./mcp-io.ts"
|
|
85
|
+
export type { BoundedLine, BoundedOutput } from "./mcp-io.ts"
|
|
84
86
|
export {
|
|
87
|
+
CHILD_INPUT_MAX_BYTES,
|
|
85
88
|
CHILD_OUTPUT_MAX_BYTES,
|
|
86
89
|
CHILD_TIMEOUT_MS,
|
|
87
90
|
LOCAL_TOOL_FETCH_TIMEOUT_MS,
|
|
88
91
|
LOCAL_TOOL_MAX_RESPONSE_BYTES,
|
|
89
92
|
notNifraResponse,
|
|
93
|
+
readBoundedLines,
|
|
90
94
|
readBoundedResponse,
|
|
91
95
|
readBoundedStream,
|
|
96
|
+
serializeBoundedJson,
|
|
92
97
|
timeoutMessage,
|
|
93
98
|
validateLocalPort,
|
|
94
99
|
} from "./mcp-io.ts"
|
|
@@ -155,6 +160,8 @@ async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTo
|
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
const ROOTS_REQUEST_ID = "nifra:roots/list"
|
|
163
|
+
const MAX_STDIO_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
164
|
+
const STDIO_ENCODER = new TextEncoder()
|
|
158
165
|
|
|
159
166
|
/** Everything derived from the project root - rebuilt wholesale when the root changes (adoption of a
|
|
160
167
|
* client workspace root), so no per-tool state can keep pointing at the old directory. */
|
|
@@ -215,17 +222,31 @@ export async function runMcpServer(
|
|
|
215
222
|
const serverInfo = { name: "nifra", version }
|
|
216
223
|
const state = createMcpProtocolState()
|
|
217
224
|
const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
|
|
218
|
-
|
|
225
|
+
let encoded: string
|
|
226
|
+
try {
|
|
227
|
+
encoded = JSON.stringify(message)
|
|
228
|
+
} catch {
|
|
229
|
+
encoded = JSON.stringify(
|
|
230
|
+
rpcError(responseIdOf(message), -32603, "MCP response is not serializable"),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
if (STDIO_ENCODER.encode(encoded).byteLength > MAX_STDIO_MESSAGE_BYTES)
|
|
234
|
+
encoded = JSON.stringify(
|
|
235
|
+
rpcError(responseIdOf(message), -32603, "MCP response exceeded the size limit"),
|
|
236
|
+
)
|
|
237
|
+
process.stdout.write(`${encoded}\n`)
|
|
219
238
|
}
|
|
220
239
|
let rootsSupported = false
|
|
240
|
+
let rootsRequestPending = false
|
|
221
241
|
const requestRoots = (): void => {
|
|
222
|
-
if (!rootsSupported) return
|
|
242
|
+
if (!rootsSupported || rootsRequestPending) return
|
|
243
|
+
rootsRequestPending = true
|
|
223
244
|
process.stdout.write(
|
|
224
245
|
`${JSON.stringify({ jsonrpc: "2.0", id: ROOTS_REQUEST_ID, method: "roots/list" })}\n`,
|
|
225
246
|
)
|
|
226
247
|
}
|
|
227
|
-
const onRootsAnswer = async (message:
|
|
228
|
-
const result =
|
|
248
|
+
const onRootsAnswer = async (message: JsonRpcResponse): Promise<void> => {
|
|
249
|
+
const result = "result" in message ? message.result : undefined
|
|
229
250
|
// An error response (or a malformed one) carries no roots: keep the current state - the absence
|
|
230
251
|
// of workspace data is not a mismatch, and never a reason to move the root.
|
|
231
252
|
if (result === undefined) return
|
|
@@ -241,15 +262,15 @@ export async function runMcpServer(
|
|
|
241
262
|
// must see the adopted root, even though dispatches themselves run concurrently.
|
|
242
263
|
let rootsUpdate: Promise<void> = Promise.resolve()
|
|
243
264
|
|
|
265
|
+
const dispatchRootsAnswer = (message: JsonRpcResponse): void => {
|
|
266
|
+
// The roots answer is a response to a request initiated by this server, not a client RPC request.
|
|
267
|
+
// Serialize adoption with normal dispatch so a following request cannot observe a half-updated root.
|
|
268
|
+
if (!rootsRequestPending) return
|
|
269
|
+
rootsRequestPending = false
|
|
270
|
+
rootsUpdate = onRootsAnswer(message).catch(() => {})
|
|
271
|
+
}
|
|
272
|
+
|
|
244
273
|
const dispatch = async (message: JsonRpcRequest): Promise<void> => {
|
|
245
|
-
// The client's answer to OUR `roots/list` request - a response (id, no method), which the pure
|
|
246
|
-
// dispatch would reject as an unknown method. Intercept it before handleRpc ever sees it.
|
|
247
|
-
if (message.method === undefined && message.id === ROOTS_REQUEST_ID) {
|
|
248
|
-
// Swallow failures: a bad answer must not poison the chain and 500 every later dispatch.
|
|
249
|
-
rootsUpdate = onRootsAnswer(message).catch(() => {})
|
|
250
|
-
await rootsUpdate
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
274
|
await rootsUpdate
|
|
254
275
|
if (message.method === "initialize") {
|
|
255
276
|
rootsSupported = clientSupportsRoots(message.params)
|
|
@@ -301,25 +322,64 @@ export async function runMcpServer(
|
|
|
301
322
|
|
|
302
323
|
const decoder = new TextDecoder()
|
|
303
324
|
let buffer = ""
|
|
325
|
+
let bufferBytes = 0
|
|
326
|
+
let discardingOversized = false
|
|
327
|
+
const processLine = (rawLine: string): void => {
|
|
328
|
+
const line = rawLine.trim()
|
|
329
|
+
if (line === "") return
|
|
330
|
+
if (STDIO_ENCODER.encode(rawLine).byteLength > MAX_STDIO_MESSAGE_BYTES) {
|
|
331
|
+
send(rpcError(null, -32600, "MCP message exceeded the size limit"))
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
let parsed: unknown
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(line) as unknown
|
|
337
|
+
} catch {
|
|
338
|
+
send(rpcError(null, -32700, "parse error"))
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (isJsonRpcRequest(parsed)) {
|
|
342
|
+
void dispatch(parsed).catch((err) => {
|
|
343
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
344
|
+
send(rpcError(parsed.id ?? null, -32603, msg))
|
|
345
|
+
})
|
|
346
|
+
} else if (isJsonRpcResponse(parsed) && parsed.id === ROOTS_REQUEST_ID) {
|
|
347
|
+
dispatchRootsAnswer(parsed)
|
|
348
|
+
} else {
|
|
349
|
+
// Do not let null, arrays, scalars, responses for unknown ids, or malformed envelopes reach the
|
|
350
|
+
// dispatch shell. In particular, reading `.id` from a hostile `null` must never crash the loop.
|
|
351
|
+
send(rpcError(null, -32600, "Invalid Request"))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
304
354
|
for await (const chunk of Bun.stdin.stream()) {
|
|
305
|
-
|
|
355
|
+
let text = decoder.decode(chunk as Uint8Array, { stream: true })
|
|
356
|
+
if (discardingOversized) {
|
|
357
|
+
const newline = text.indexOf("\n")
|
|
358
|
+
if (newline === -1) continue
|
|
359
|
+
text = text.slice(newline + 1)
|
|
360
|
+
discardingOversized = false
|
|
361
|
+
}
|
|
362
|
+
buffer += text
|
|
363
|
+
bufferBytes += STDIO_ENCODER.encode(text).byteLength
|
|
306
364
|
let nl = buffer.indexOf("\n")
|
|
307
365
|
while (nl !== -1) {
|
|
308
|
-
const line = buffer.slice(0, nl)
|
|
366
|
+
const line = buffer.slice(0, nl)
|
|
309
367
|
buffer = buffer.slice(nl + 1)
|
|
368
|
+
bufferBytes = Math.max(0, bufferBytes - STDIO_ENCODER.encode(line).byteLength - 1)
|
|
310
369
|
nl = buffer.indexOf("\n")
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
void dispatch(message).catch((err) => {
|
|
320
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
321
|
-
send(rpcError(message.id ?? null, -32603, msg))
|
|
322
|
-
})
|
|
370
|
+
processLine(line)
|
|
371
|
+
}
|
|
372
|
+
if (bufferBytes > MAX_STDIO_MESSAGE_BYTES) {
|
|
373
|
+
buffer = ""
|
|
374
|
+
bufferBytes = 0
|
|
375
|
+
discardingOversized = true
|
|
376
|
+
send(rpcError(null, -32600, "MCP message exceeded the size limit"))
|
|
323
377
|
}
|
|
324
378
|
}
|
|
325
379
|
}
|
|
380
|
+
|
|
381
|
+
function responseIdOf(message: JsonRpcResponse | JsonRpcNotification): string | number | null {
|
|
382
|
+
if (!("id" in message)) return null
|
|
383
|
+
const id = message.id
|
|
384
|
+
return id === null || typeof id === "string" || typeof id === "number" ? id : null
|
|
385
|
+
}
|
package/src/project-facts.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { CapabilityProjectReport } from "./capabilities-tool.ts"
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CheckAssuranceContext,
|
|
4
|
+
CheckConfig,
|
|
5
|
+
CheckTypecheckResult,
|
|
6
|
+
} from "./check-diagnostics.ts"
|
|
3
7
|
import type {
|
|
4
8
|
ManifestDriftFinding,
|
|
5
9
|
SourceFinding,
|
|
@@ -12,6 +16,8 @@ import type { RulePack, SourceIndex } from "./rules/index.ts"
|
|
|
12
16
|
|
|
13
17
|
export interface ProjectSourceFindings {
|
|
14
18
|
readonly fetches: readonly SourceFinding[]
|
|
19
|
+
/** Hand-rolled `new EventSource("/…")` / `new WebSocket("/…")` to the app's own streaming surface. */
|
|
20
|
+
readonly streams: readonly SourceFinding[]
|
|
15
21
|
readonly untypedClients: readonly SourceFinding[]
|
|
16
22
|
readonly removedImports: readonly SourceFinding[]
|
|
17
23
|
readonly responseRoutes: readonly SourceFinding[]
|
|
@@ -30,6 +36,22 @@ export interface ProjectPolicyFacts {
|
|
|
30
36
|
readonly rulePacks: readonly RulePack[]
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
export interface ContractCheckFacts {
|
|
40
|
+
readonly present: boolean
|
|
41
|
+
readonly vacuous: boolean
|
|
42
|
+
readonly diagnostics: readonly { readonly route?: string; readonly message: string }[]
|
|
43
|
+
readonly error?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check-wide inputs that rules format into diagnostics. Scanners populate facts; rules own policy. */
|
|
47
|
+
export interface ProjectCheckFacts {
|
|
48
|
+
readonly typecheck: CheckTypecheckResult
|
|
49
|
+
readonly sqlCompilerAvailable: boolean
|
|
50
|
+
readonly checkConfigError?: string
|
|
51
|
+
readonly checkConfigWarnings: readonly string[]
|
|
52
|
+
readonly contracts: ContractCheckFacts
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
export interface ProjectFacts {
|
|
34
56
|
readonly source: SourceIndex
|
|
35
57
|
readonly routes: readonly StaticRouteFinding[]
|
|
@@ -37,11 +59,11 @@ export interface ProjectFacts {
|
|
|
37
59
|
readonly packages: ProjectPackageFacts
|
|
38
60
|
readonly pipeline?: PipelineReport
|
|
39
61
|
readonly policies: ProjectPolicyFacts
|
|
62
|
+
readonly check: ProjectCheckFacts
|
|
40
63
|
readonly sourceFindings: ProjectSourceFindings
|
|
41
|
-
readonly legacyDiagnostics: readonly CheckDiagnostic[]
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
export type ProjectFactsSeed =
|
|
66
|
+
export type ProjectFactsSeed = ProjectFacts
|
|
45
67
|
|
|
46
68
|
function freezeFindings<T extends object>(findings: readonly T[]): readonly T[] {
|
|
47
69
|
return Object.freeze(findings.map((finding) => Object.freeze({ ...finding })))
|
|
@@ -52,12 +74,10 @@ function freezeFindings<T extends object>(findings: readonly T[]): readonly T[]
|
|
|
52
74
|
* these copied, frozen collections. Policy/config objects remain caller-owned because assurance config
|
|
53
75
|
* can contain executable adapters and must not be frozen as a side effect of checking.
|
|
54
76
|
*/
|
|
55
|
-
export function freezeProjectFacts(
|
|
56
|
-
seed: ProjectFactsSeed,
|
|
57
|
-
legacyDiagnostics: readonly CheckDiagnostic[],
|
|
58
|
-
): ProjectFacts {
|
|
77
|
+
export function freezeProjectFacts(seed: ProjectFactsSeed): ProjectFacts {
|
|
59
78
|
const sourceFindings: ProjectSourceFindings = Object.freeze({
|
|
60
79
|
fetches: freezeFindings(seed.sourceFindings.fetches),
|
|
80
|
+
streams: freezeFindings(seed.sourceFindings.streams),
|
|
61
81
|
untypedClients: freezeFindings(seed.sourceFindings.untypedClients),
|
|
62
82
|
removedImports: freezeFindings(seed.sourceFindings.removedImports),
|
|
63
83
|
responseRoutes: freezeFindings(seed.sourceFindings.responseRoutes),
|
|
@@ -67,6 +87,15 @@ export function freezeProjectFacts(
|
|
|
67
87
|
...seed.policies,
|
|
68
88
|
rulePacks: Object.freeze([...seed.policies.rulePacks]),
|
|
69
89
|
})
|
|
90
|
+
const check = Object.freeze({
|
|
91
|
+
...seed.check,
|
|
92
|
+
typecheck: Object.freeze({ ...seed.check.typecheck }),
|
|
93
|
+
checkConfigWarnings: Object.freeze([...seed.check.checkConfigWarnings]),
|
|
94
|
+
contracts: Object.freeze({
|
|
95
|
+
...seed.check.contracts,
|
|
96
|
+
diagnostics: freezeFindings(seed.check.contracts.diagnostics),
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
70
99
|
return Object.freeze({
|
|
71
100
|
...seed,
|
|
72
101
|
source: Object.freeze(seed.source),
|
|
@@ -78,7 +107,7 @@ export function freezeProjectFacts(
|
|
|
78
107
|
}),
|
|
79
108
|
...(seed.pipeline === undefined ? {} : { pipeline: Object.freeze(seed.pipeline) }),
|
|
80
109
|
policies,
|
|
110
|
+
check,
|
|
81
111
|
sourceFindings,
|
|
82
|
-
legacyDiagnostics: freezeFindings(legacyDiagnostics),
|
|
83
112
|
})
|
|
84
113
|
}
|