@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.
Files changed (119) hide show
  1. package/dist/check-diagnostics.d.ts +3 -15
  2. package/dist/check-diagnostics.d.ts.map +1 -1
  3. package/dist/check-diagnostics.js +149 -713
  4. package/dist/check-diagnostics.js.map +1 -1
  5. package/dist/check-scan.d.ts +9 -0
  6. package/dist/check-scan.d.ts.map +1 -1
  7. package/dist/check-scan.js +32 -0
  8. package/dist/check-scan.js.map +1 -1
  9. package/dist/check.d.ts +1 -0
  10. package/dist/check.d.ts.map +1 -1
  11. package/dist/check.js +63 -8
  12. package/dist/check.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +46 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/command-catalog.d.ts +4 -1
  17. package/dist/command-catalog.d.ts.map +1 -1
  18. package/dist/command-catalog.js +28 -7
  19. package/dist/command-catalog.js.map +1 -1
  20. package/dist/contracts.d.ts +3 -2
  21. package/dist/contracts.d.ts.map +1 -1
  22. package/dist/contracts.js +4 -3
  23. package/dist/contracts.js.map +1 -1
  24. package/dist/diagnostics.d.ts +86 -0
  25. package/dist/diagnostics.d.ts.map +1 -1
  26. package/dist/diagnostics.js +85 -0
  27. package/dist/diagnostics.js.map +1 -1
  28. package/dist/introspect.d.ts +1 -3
  29. package/dist/introspect.d.ts.map +1 -1
  30. package/dist/introspect.js +1 -47
  31. package/dist/introspect.js.map +1 -1
  32. package/dist/load.d.ts +5 -0
  33. package/dist/load.d.ts.map +1 -1
  34. package/dist/load.js.map +1 -1
  35. package/dist/mcp-context.d.ts.map +1 -1
  36. package/dist/mcp-context.js +4 -5
  37. package/dist/mcp-context.js.map +1 -1
  38. package/dist/mcp-exec.d.ts +1 -0
  39. package/dist/mcp-exec.d.ts.map +1 -1
  40. package/dist/mcp-exec.js +133 -25
  41. package/dist/mcp-exec.js.map +1 -1
  42. package/dist/mcp-http.d.ts.map +1 -1
  43. package/dist/mcp-http.js +4 -1
  44. package/dist/mcp-http.js.map +1 -1
  45. package/dist/mcp-io.d.ts +12 -0
  46. package/dist/mcp-io.d.ts.map +1 -1
  47. package/dist/mcp-io.js +68 -0
  48. package/dist/mcp-io.js.map +1 -1
  49. package/dist/mcp-path.d.ts +10 -0
  50. package/dist/mcp-path.d.ts.map +1 -0
  51. package/dist/mcp-path.js +51 -0
  52. package/dist/mcp-path.js.map +1 -0
  53. package/dist/mcp-reflect.d.ts.map +1 -1
  54. package/dist/mcp-reflect.js +2 -2
  55. package/dist/mcp-reflect.js.map +1 -1
  56. package/dist/mcp-render.d.ts.map +1 -1
  57. package/dist/mcp-render.js +62 -43
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-root.d.ts.map +1 -1
  60. package/dist/mcp-root.js +14 -2
  61. package/dist/mcp-root.js.map +1 -1
  62. package/dist/mcp-run.d.ts.map +1 -1
  63. package/dist/mcp-run.js +82 -51
  64. package/dist/mcp-run.js.map +1 -1
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.d.ts.map +1 -1
  67. package/dist/mcp.js +84 -29
  68. package/dist/mcp.js.map +1 -1
  69. package/dist/project-facts.d.ts +23 -4
  70. package/dist/project-facts.d.ts.map +1 -1
  71. package/dist/project-facts.js +12 -2
  72. package/dist/project-facts.js.map +1 -1
  73. package/dist/review.d.ts +21 -0
  74. package/dist/review.d.ts.map +1 -0
  75. package/dist/review.js +819 -0
  76. package/dist/review.js.map +1 -0
  77. package/dist/rules/legacy.d.ts +3 -1
  78. package/dist/rules/legacy.d.ts.map +1 -1
  79. package/dist/rules/legacy.js +796 -34
  80. package/dist/rules/legacy.js.map +1 -1
  81. package/dist/scaffold.d.ts.map +1 -1
  82. package/dist/scaffold.js +23 -4
  83. package/dist/scaffold.js.map +1 -1
  84. package/dist/verification-plan.d.ts.map +1 -1
  85. package/dist/verification-plan.js +4 -0
  86. package/dist/verification-plan.js.map +1 -1
  87. package/dist/work-graph.d.ts +3 -0
  88. package/dist/work-graph.d.ts.map +1 -1
  89. package/dist/work-graph.js +4 -3
  90. package/dist/work-graph.js.map +1 -1
  91. package/docs/examples.json +10 -4
  92. package/docs/llms-full.txt +500 -46
  93. package/docs/types.json +1123 -82
  94. package/package.json +10 -9
  95. package/src/check-diagnostics.ts +181 -796
  96. package/src/check-scan.ts +36 -0
  97. package/src/check.ts +65 -12
  98. package/src/cli.ts +53 -7
  99. package/src/command-catalog.ts +33 -7
  100. package/src/contracts.ts +12 -3
  101. package/src/diagnostics.ts +179 -0
  102. package/src/introspect.ts +3 -55
  103. package/src/load.ts +5 -0
  104. package/src/mcp-context.ts +4 -6
  105. package/src/mcp-exec.ts +142 -30
  106. package/src/mcp-http.ts +4 -1
  107. package/src/mcp-io.ts +73 -0
  108. package/src/mcp-path.ts +50 -0
  109. package/src/mcp-reflect.ts +3 -2
  110. package/src/mcp-render.ts +70 -40
  111. package/src/mcp-root.ts +13 -2
  112. package/src/mcp-run.ts +90 -50
  113. package/src/mcp.ts +87 -27
  114. package/src/project-facts.ts +37 -8
  115. package/src/review.ts +983 -0
  116. package/src/rules/legacy.ts +915 -35
  117. package/src/scaffold.ts +29 -4
  118. package/src/verification-plan.ts +14 -0
  119. 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
- process.stdout.write(`${JSON.stringify({ id, output })}\n`)
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 chunk of Bun.stdin.stream()) {
109
- buffer += decoder.decode(chunk as Uint8Array, { stream: true })
110
- let nl = buffer.indexOf("\n")
111
- while (nl !== -1) {
112
- const line = buffer.slice(0, nl).trim()
113
- buffer = buffer.slice(nl + 1)
114
- nl = buffer.indexOf("\n")
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
- let message: RenderWorkerMessage
118
- try {
119
- message = JSON.parse(line) as RenderWorkerMessage
120
- } catch {
121
- send(null, { error: "invalid worker input: expected JSON line" })
122
- continue
123
- }
124
- const requests = message.input?.requests
125
- if (!Array.isArray(requests)) {
126
- send(message.id, { error: "expected { requests: [...] }" })
127
- continue
128
- }
129
- if ("error" in built) {
130
- send(message.id, built)
131
- continue
132
- }
133
- try {
134
- send(message.id, {
135
- results: await runApp(built.app, requests as Parameters<typeof runApp>[1]),
136
- })
137
- } catch (err) {
138
- send(message.id, { error: errString(err) })
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
- try {
167
- const { requests } = JSON.parse(await Bun.stdin.text()) as { requests: unknown }
168
- output = await renderPages(cwd, requests)
169
- } catch {
170
- output = { error: "invalid input: expected JSON { requests: [...] }" }
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(Bun.stdout, JSON.stringify(output, null, 2))
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://")) continue
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
- paths.push(fileURLToPath(parsed))
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
- process.stdout.write(`${JSON.stringify({ id, output })}\n`)
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 chunk of Bun.stdin.stream()) {
112
- buffer += decoder.decode(chunk as Uint8Array, { stream: true })
113
- let nl = buffer.indexOf("\n")
114
- while (nl !== -1) {
115
- const line = buffer.slice(0, nl).trim()
116
- buffer = buffer.slice(nl + 1)
117
- nl = buffer.indexOf("\n")
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
- let message: WorkerMessage
121
- try {
122
- message = JSON.parse(line) as WorkerMessage
123
- } catch {
124
- send(null, { error: "invalid worker input: expected JSON line" })
125
- continue
126
- }
127
- const requests = message.input?.requests
128
- const entry = message.input?.entry
129
- if (!Array.isArray(requests)) {
130
- send(message.id, { error: "expected { requests: [...] }" })
131
- continue
132
- }
133
- const key = entry ?? ""
134
- let loaded = apps.get(key)
135
- if (loaded === undefined) {
136
- loaded = loadBackend(cwd, entry)
137
- apps.set(key, loaded)
138
- }
139
- const app = await loaded
140
- if ("error" in app) {
141
- send(message.id, app)
142
- continue
143
- }
144
- try {
145
- send(message.id, {
146
- results: await runApp(app.app, requests as Parameters<typeof runApp>[1]),
147
- })
148
- } catch (err) {
149
- send(message.id, { error: errString(err) })
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
- try {
165
- const { requests, entry } = JSON.parse(await Bun.stdin.text()) as {
166
- requests: unknown
167
- entry?: string
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(JSON.stringify(output, null, 2))
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
- process.stdout.write(`${JSON.stringify(message)}\n`)
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: JsonRpcRequest): Promise<void> => {
228
- const result = (message as { result?: unknown }).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
- buffer += decoder.decode(chunk as Uint8Array, { stream: true })
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).trim()
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
- if (line === "") continue
312
- let message: JsonRpcRequest
313
- try {
314
- message = JSON.parse(line)
315
- } catch {
316
- send(rpcError(null, -32700, "parse error"))
317
- continue
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
+ }
@@ -1,5 +1,9 @@
1
1
  import type { CapabilityProjectReport } from "./capabilities-tool.ts"
2
- import type { CheckAssuranceContext, CheckConfig, CheckDiagnostic } from "./check-diagnostics.ts"
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 = Omit<ProjectFacts, "legacyDiagnostics">
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
  }