@nifrajs/cli 2.14.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +6 -1
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +3 -2
- package/dist/assure.js.map +1 -1
- package/dist/check-diagnostics.d.ts +165 -0
- package/dist/check-diagnostics.d.ts.map +1 -0
- package/dist/check-diagnostics.js +1009 -0
- package/dist/check-diagnostics.js.map +1 -0
- package/dist/check-scan.d.ts +245 -0
- package/dist/check-scan.d.ts.map +1 -0
- package/dist/check-scan.js +1599 -0
- package/dist/check-scan.js.map +1 -0
- package/dist/check.d.ts +4 -324
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +8 -2346
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -10
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +27 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +142 -2
- package/dist/command-catalog.js.map +1 -1
- package/dist/doctor.d.ts +42 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -7
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.d.ts +57 -0
- package/dist/frontend-guidance.d.ts.map +1 -0
- package/dist/frontend-guidance.js +195 -0
- package/dist/frontend-guidance.js.map +1 -0
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +6 -0
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +4 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +48 -0
- package/dist/mcp-context.d.ts.map +1 -0
- package/dist/mcp-context.js +215 -0
- package/dist/mcp-context.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +31 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +74 -0
- package/dist/mcp-exec.d.ts.map +1 -0
- package/dist/mcp-exec.js +844 -0
- package/dist/mcp-exec.js.map +1 -0
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -0
- package/dist/mcp-io.js +100 -0
- package/dist/mcp-io.js.map +1 -0
- package/dist/mcp-reflect.d.ts +16 -0
- package/dist/mcp-reflect.d.ts.map +1 -0
- package/dist/mcp-reflect.js +101 -0
- package/dist/mcp-reflect.js.map +1 -0
- package/dist/mcp-root.d.ts +7 -0
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +51 -3
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp.d.ts +10 -84
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +13 -1170
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +5 -10
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -2
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +21 -0
- package/dist/openapi-types.d.ts.map +1 -0
- package/dist/openapi-types.js +214 -0
- package/dist/openapi-types.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +2 -1
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +2 -1
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +4 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +6 -123
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/codes.d.ts +4 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +4 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/islands.d.ts +4 -0
- package/dist/rules/islands.d.ts.map +1 -0
- package/dist/rules/islands.js +155 -0
- package/dist/rules/islands.js.map +1 -0
- package/dist/rules/nano.d.ts +6 -0
- package/dist/rules/nano.d.ts.map +1 -0
- package/dist/rules/nano.js +264 -0
- package/dist/rules/nano.js.map +1 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +17 -30
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +38 -4
- package/dist/rules/security.js.map +1 -1
- package/dist/scaffold.d.ts +12 -6
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +149 -10
- package/dist/scaffold.js.map +1 -1
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -3
- package/dist/search-terms.js.map +1 -1
- package/dist/stylex-migrate.d.ts +38 -0
- package/dist/stylex-migrate.d.ts.map +1 -0
- package/dist/stylex-migrate.js +1052 -0
- package/dist/stylex-migrate.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/verification-plan.d.ts +24 -0
- package/dist/verification-plan.d.ts.map +1 -0
- package/dist/verification-plan.js +116 -0
- package/dist/verification-plan.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +6 -3
- package/dist/work-graph.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +37 -1
- package/docs/llms-full.txt +1598 -44
- package/docs/types.json +5157 -172
- package/package.json +9 -9
- package/src/assure-hydration.ts +6 -1
- package/src/assure.ts +4 -2
- package/src/check-diagnostics.ts +1219 -0
- package/src/check-scan.ts +1825 -0
- package/src/check.ts +47 -2731
- package/src/cli.ts +291 -10
- package/src/command-catalog.ts +177 -2
- package/src/doctor.ts +99 -8
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/frontend-guidance.ts +259 -0
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/levels-tool.ts +6 -0
- package/src/load.ts +4 -1
- package/src/mcp-context.ts +266 -0
- package/src/mcp-docs-tools.ts +37 -0
- package/src/mcp-exec.ts +1040 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +118 -0
- package/src/mcp-reflect.ts +140 -0
- package/src/mcp-root.ts +62 -4
- package/src/mcp.ts +61 -1436
- package/src/openapi-tool.ts +32 -2
- package/src/openapi-types.ts +261 -0
- package/src/port.ts +2 -1
- package/src/project-facts.ts +2 -4
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +22 -147
- package/src/rules/codes.ts +4 -0
- package/src/rules/islands.ts +171 -0
- package/src/rules/nano.ts +295 -0
- package/src/rules/routes.ts +17 -30
- package/src/rules/security.ts +40 -4
- package/src/scaffold.ts +169 -9
- package/src/search-terms.ts +7 -3
- package/src/stylex-migrate.ts +1139 -0
- package/src/sync-manifest.ts +2 -1
- package/src/verification-plan.ts +242 -0
- package/src/work-graph.ts +6 -3
- package/src/workspace-link.ts +80 -0
package/src/mcp.ts
CHANGED
|
@@ -35,24 +35,11 @@
|
|
|
35
35
|
* module is the I/O shell (stdin loop, tool wiring, the run subprocess).
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import { basename, isAbsolute, relative, resolve, sep } from "node:path"
|
|
41
|
-
import { fileURLToPath } from "node:url"
|
|
42
|
-
import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
43
|
-
import { Glob } from "bun"
|
|
44
|
-
import {
|
|
45
|
-
type CommandCatalogEntry,
|
|
46
|
-
type CommandCtx,
|
|
47
|
-
type CommandSpec,
|
|
48
|
-
commandCatalog,
|
|
49
|
-
commandMcpName,
|
|
50
|
-
findCommandSpec,
|
|
51
|
-
} from "./command-catalog.ts"
|
|
52
|
-
import { collectContractProof } from "./contract-proof.ts"
|
|
38
|
+
import { stat } from "node:fs/promises"
|
|
39
|
+
import { resolve } from "node:path"
|
|
53
40
|
import { loadDocsCorpus } from "./docs-search.ts"
|
|
54
41
|
import { loadExamplesCorpus } from "./examples.ts"
|
|
55
|
-
import type {
|
|
42
|
+
import type { LoadedApp } from "./load.ts"
|
|
56
43
|
import { detectMonorepo, loadMonorepoApps } from "./load.ts"
|
|
57
44
|
import { docsTools } from "./mcp-docs-tools.ts"
|
|
58
45
|
import {
|
|
@@ -61,1389 +48,80 @@ import {
|
|
|
61
48
|
type JsonRpcNotification,
|
|
62
49
|
type JsonRpcRequest,
|
|
63
50
|
type JsonRpcResponse,
|
|
64
|
-
type McpContentBlock,
|
|
65
51
|
type McpPrompt,
|
|
66
|
-
type McpPromptMessage,
|
|
67
52
|
type McpResource,
|
|
68
53
|
type McpServerFeatures,
|
|
69
54
|
type McpTool,
|
|
70
|
-
type McpToolContext,
|
|
71
|
-
type McpToolResult,
|
|
72
55
|
rpcError,
|
|
73
56
|
} from "./mcp-protocol.ts"
|
|
57
|
+
import {
|
|
58
|
+
extractBackendPrompts,
|
|
59
|
+
extractBackendResources,
|
|
60
|
+
extractBackendTools,
|
|
61
|
+
} from "./mcp-reflect.ts"
|
|
62
|
+
import { loadTypesCorpus } from "./types-search.ts"
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
extractBackendPrompts,
|
|
66
|
+
extractBackendResources,
|
|
67
|
+
extractBackendTools,
|
|
68
|
+
} from "./mcp-reflect.ts"
|
|
69
|
+
|
|
74
70
|
import {
|
|
75
71
|
applyClientRoots,
|
|
72
|
+
clientSupportsRoots,
|
|
76
73
|
detectToolingDrift,
|
|
77
74
|
driftNote,
|
|
78
|
-
|
|
75
|
+
guardTools,
|
|
79
76
|
pathsFromRootsResult,
|
|
80
77
|
resolveRootState,
|
|
81
78
|
rootInstructions,
|
|
82
79
|
rootVerdict,
|
|
83
80
|
type ToolingDrift,
|
|
84
81
|
} from "./mcp-root.ts"
|
|
85
|
-
import { loadTypesCorpus } from "./types-search.ts"
|
|
86
|
-
|
|
87
|
-
/** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
|
|
88
|
-
* dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
|
|
89
|
-
function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
|
|
90
|
-
return fileURLToPath(new URL(import.meta.url)).replace(/mcp\.(ts|js)$/, `${name}.$1`)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Spawn `bun <child> <cwd>`, pipe `input` to its stdin, return its stdout (or a stderr-backed error). */
|
|
94
|
-
/**
|
|
95
|
-
* How long a child gets before it is killed.
|
|
96
|
-
*
|
|
97
|
-
* A child loads the app, which runs its module side effects - a database pool, a Redis client, a
|
|
98
|
-
* metrics interval. Any of those keeps the child's event loop alive after the work is done, and the
|
|
99
|
-
* parent is sitting in `await proc.exited`. The children now exit explicitly, so this is the backstop
|
|
100
|
-
* rather than the fix: without it a single wedged child blocks an agent indefinitely, and the caller
|
|
101
|
-
* has no way to tell "still working" from "never returning".
|
|
102
|
-
*
|
|
103
|
-
* Generous enough for a cold start that compiles route modules, short enough that a hang is reported
|
|
104
|
-
* within one attention span rather than after minutes of polling.
|
|
105
|
-
*/
|
|
106
|
-
const CHILD_TIMEOUT_MS = 30_000
|
|
107
|
-
|
|
108
|
-
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
109
|
-
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
110
|
-
export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
|
|
111
|
-
export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
|
|
112
|
-
|
|
113
|
-
/** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
|
|
114
|
-
export function validateLocalPort(port: unknown): number | undefined {
|
|
115
|
-
return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
|
|
116
|
-
? port
|
|
117
|
-
: undefined
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Read a dev-tool response without allowing an untrusted localhost service to allocate unbounded memory. */
|
|
121
|
-
export async function readBoundedResponse(
|
|
122
|
-
response: Response,
|
|
123
|
-
maxBytes = LOCAL_TOOL_MAX_RESPONSE_BYTES,
|
|
124
|
-
): Promise<string> {
|
|
125
|
-
const declared = response.headers.get("content-length")
|
|
126
|
-
if (declared !== null) {
|
|
127
|
-
const length = Number(declared)
|
|
128
|
-
if (Number.isFinite(length) && length > maxBytes)
|
|
129
|
-
throw new Error("response exceeded the size limit")
|
|
130
|
-
}
|
|
131
|
-
if (response.body === null) return ""
|
|
132
|
-
const reader = response.body.getReader()
|
|
133
|
-
const chunks: Uint8Array[] = []
|
|
134
|
-
let total = 0
|
|
135
|
-
try {
|
|
136
|
-
for (;;) {
|
|
137
|
-
const { done, value } = await reader.read()
|
|
138
|
-
if (done) break
|
|
139
|
-
if (value === undefined) continue
|
|
140
|
-
total += value.byteLength
|
|
141
|
-
if (total > maxBytes) {
|
|
142
|
-
await reader.cancel()
|
|
143
|
-
throw new Error("response exceeded the size limit")
|
|
144
|
-
}
|
|
145
|
-
chunks.push(value)
|
|
146
|
-
}
|
|
147
|
-
} finally {
|
|
148
|
-
reader.releaseLock()
|
|
149
|
-
}
|
|
150
|
-
const bytes = new Uint8Array(total)
|
|
151
|
-
let offset = 0
|
|
152
|
-
for (const chunk of chunks) {
|
|
153
|
-
bytes.set(chunk, offset)
|
|
154
|
-
offset += chunk.byteLength
|
|
155
|
-
}
|
|
156
|
-
return new TextDecoder().decode(bytes)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const notNifraResponse = (service: string): string =>
|
|
160
|
-
JSON.stringify(
|
|
161
|
-
{
|
|
162
|
-
code: "NIFRA_NOT_DEV_SERVER",
|
|
163
|
-
message: `The service on this port is not a Nifra ${service} endpoint.`,
|
|
164
|
-
},
|
|
165
|
-
null,
|
|
166
|
-
2,
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
const timeoutMessage = (label: string, ms: number): string =>
|
|
170
|
-
`${label} timed out after ${ms / 1000}s and was terminated.\n` +
|
|
171
|
-
`The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
|
|
172
|
-
`that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
|
|
173
|
-
`rather than lazily. Check for top-level connections in the app entry or anything it imports.`
|
|
174
|
-
|
|
175
|
-
async function spawnChild(
|
|
176
|
-
child: "mcp-run" | "mcp-render" | "mcp-ws",
|
|
177
|
-
cwd: string,
|
|
178
|
-
input: unknown,
|
|
179
|
-
label: string,
|
|
180
|
-
signal?: AbortSignal,
|
|
181
|
-
): Promise<string> {
|
|
182
|
-
if (signal?.aborted) return `${label} cancelled before it started.`
|
|
183
|
-
const proc = Bun.spawn(["bun", childPath(child), cwd], {
|
|
184
|
-
stdin: "pipe",
|
|
185
|
-
stdout: "pipe",
|
|
186
|
-
stderr: "pipe",
|
|
187
|
-
})
|
|
188
|
-
const abort = (): void => proc.kill()
|
|
189
|
-
signal?.addEventListener("abort", abort, { once: true })
|
|
190
|
-
let timedOut = false
|
|
191
|
-
const timer = setTimeout(() => {
|
|
192
|
-
timedOut = true
|
|
193
|
-
proc.kill()
|
|
194
|
-
}, CHILD_TIMEOUT_MS)
|
|
195
|
-
try {
|
|
196
|
-
proc.stdin.write(JSON.stringify(input))
|
|
197
|
-
await proc.stdin.end()
|
|
198
|
-
const [out, err] = await Promise.all([
|
|
199
|
-
new Response(proc.stdout).text(),
|
|
200
|
-
new Response(proc.stderr).text(),
|
|
201
|
-
])
|
|
202
|
-
await proc.exited
|
|
203
|
-
if (signal?.aborted) {
|
|
204
|
-
const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
|
|
205
|
-
return `${label} cancelled${reason}.`
|
|
206
|
-
}
|
|
207
|
-
// A killed child may still have flushed a complete result before the timer fired; prefer real
|
|
208
|
-
// output over the timeout message so a slow-but-successful render is not reported as a failure.
|
|
209
|
-
const text = out.trim()
|
|
210
|
-
if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
|
|
211
|
-
return text || `${label} failed:\n${err.trim() || "(no output)"}`
|
|
212
|
-
} finally {
|
|
213
|
-
clearTimeout(timer)
|
|
214
|
-
signal?.removeEventListener("abort", abort)
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
|
|
219
|
-
const WARM_RUN_IGNORED =
|
|
220
|
-
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
|
|
221
|
-
const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
|
|
222
|
-
|
|
223
|
-
async function warmRunFingerprint(cwd: string): Promise<string> {
|
|
224
|
-
const parts: string[] = []
|
|
225
|
-
for await (const rel of WARM_RUN_GLOB.scan({ cwd, dot: false })) {
|
|
226
|
-
if (WARM_RUN_IGNORED.test(rel)) continue
|
|
227
|
-
try {
|
|
228
|
-
const s = await stat(resolve(cwd, rel))
|
|
229
|
-
if (s.isFile()) parts.push(`${rel}:${s.mtimeMs}:${s.size}`)
|
|
230
|
-
} catch {
|
|
231
|
-
// A file can disappear while an agent is editing. The next call will rescan the settled tree.
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
for (const rel of WARM_RUN_EXTRA_FILES) {
|
|
235
|
-
parts.push(`${rel}:${await fileFingerprint(resolve(cwd, rel))}`)
|
|
236
|
-
}
|
|
237
|
-
return cacheToken(parts.sort().join("|"))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function boundedAppend(current: string, next: string, max = 12_000): string {
|
|
241
|
-
const combined = `${current}${next}`
|
|
242
|
-
return combined.length <= max ? combined : combined.slice(combined.length - max)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
|
|
246
|
-
readonly stdin: { write(input: string | Uint8Array): unknown }
|
|
247
|
-
readonly stdout: ReadableStream<Uint8Array>
|
|
248
|
-
readonly stderr: ReadableStream<Uint8Array>
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
|
|
252
|
-
* reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
|
|
253
|
-
* powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
|
|
254
|
-
* cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
|
|
255
|
-
* replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
|
|
256
|
-
* concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
|
|
257
|
-
export class WarmWorker {
|
|
258
|
-
private readonly proc: PipeSubprocess
|
|
259
|
-
private readonly pending = new Map<
|
|
260
|
-
number,
|
|
261
|
-
{
|
|
262
|
-
readonly resolve: (text: string) => void
|
|
263
|
-
readonly reject: (err: Error) => void
|
|
264
|
-
readonly cleanup: () => void
|
|
265
|
-
}
|
|
266
|
-
>()
|
|
267
|
-
private stdoutBuffer = ""
|
|
268
|
-
private stderrBuffer = ""
|
|
269
|
-
private nextId = 0
|
|
270
|
-
private closed = false
|
|
271
|
-
|
|
272
|
-
constructor(
|
|
273
|
-
child: "mcp-run" | "mcp-render",
|
|
274
|
-
cwd: string,
|
|
275
|
-
readonly fingerprint: string,
|
|
276
|
-
private readonly label: string,
|
|
277
|
-
) {
|
|
278
|
-
this.proc = Bun.spawn(["bun", childPath(child), cwd, "--worker"], {
|
|
279
|
-
stdin: "pipe",
|
|
280
|
-
stdout: "pipe",
|
|
281
|
-
stderr: "pipe",
|
|
282
|
-
}) as PipeSubprocess
|
|
283
|
-
void this.readStdout()
|
|
284
|
-
void this.readStderr()
|
|
285
|
-
void this.proc.exited.then((code) => {
|
|
286
|
-
this.closed = true
|
|
287
|
-
const err = new Error(
|
|
288
|
-
`warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
|
|
289
|
-
)
|
|
290
|
-
for (const pending of this.pending.values()) {
|
|
291
|
-
pending.cleanup()
|
|
292
|
-
pending.reject(err)
|
|
293
|
-
}
|
|
294
|
-
this.pending.clear()
|
|
295
|
-
})
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
stop(): void {
|
|
299
|
-
if (!this.closed) this.proc.kill()
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async request(input: unknown, signal?: AbortSignal): Promise<string> {
|
|
303
|
-
if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
|
|
304
|
-
if (signal?.aborted) return `${this.label} cancelled before it started.`
|
|
305
|
-
const id = ++this.nextId
|
|
306
|
-
return new Promise((resolve, reject) => {
|
|
307
|
-
const abort = (): void => {
|
|
308
|
-
// Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
|
|
309
|
-
// across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
|
|
310
|
-
// process here would reject every OTHER in-flight request via the `exited` handler and force a
|
|
311
|
-
// cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
|
|
312
|
-
this.pending.delete(id)
|
|
313
|
-
clearTimeout(timer)
|
|
314
|
-
const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
|
|
315
|
-
resolve(`${this.label} cancelled${reason}.`)
|
|
316
|
-
}
|
|
317
|
-
// Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
|
|
318
|
-
// promise unsettled forever. Drop the id and replace the worker - unlike a per-request cancel,
|
|
319
|
-
// a timeout means the worker itself is suspect, so the next call should start from a fresh one.
|
|
320
|
-
const timer = setTimeout(() => {
|
|
321
|
-
if (!this.pending.delete(id)) return
|
|
322
|
-
signal?.removeEventListener("abort", abort)
|
|
323
|
-
this.stop()
|
|
324
|
-
resolve(timeoutMessage(this.label, CHILD_TIMEOUT_MS))
|
|
325
|
-
}, CHILD_TIMEOUT_MS)
|
|
326
|
-
const cleanup = (): void => {
|
|
327
|
-
clearTimeout(timer)
|
|
328
|
-
signal?.removeEventListener("abort", abort)
|
|
329
|
-
}
|
|
330
|
-
signal?.addEventListener("abort", abort, { once: true })
|
|
331
|
-
this.pending.set(id, { resolve, reject, cleanup })
|
|
332
|
-
try {
|
|
333
|
-
this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`)
|
|
334
|
-
} catch (err) {
|
|
335
|
-
this.pending.delete(id)
|
|
336
|
-
cleanup()
|
|
337
|
-
reject(err instanceof Error ? err : new Error(String(err)))
|
|
338
|
-
}
|
|
339
|
-
})
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private async readStdout(): Promise<void> {
|
|
343
|
-
const decoder = new TextDecoder()
|
|
344
|
-
const reader = this.proc.stdout.getReader()
|
|
345
|
-
for (;;) {
|
|
346
|
-
const { done, value } = await reader.read()
|
|
347
|
-
if (done) break
|
|
348
|
-
this.stdoutBuffer += decoder.decode(value, { stream: true })
|
|
349
|
-
let nl = this.stdoutBuffer.indexOf("\n")
|
|
350
|
-
while (nl !== -1) {
|
|
351
|
-
const line = this.stdoutBuffer.slice(0, nl).trim()
|
|
352
|
-
this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
|
|
353
|
-
nl = this.stdoutBuffer.indexOf("\n")
|
|
354
|
-
if (line !== "") this.handleLine(line)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private async readStderr(): Promise<void> {
|
|
360
|
-
const decoder = new TextDecoder()
|
|
361
|
-
const reader = this.proc.stderr.getReader()
|
|
362
|
-
for (;;) {
|
|
363
|
-
const { done, value } = await reader.read()
|
|
364
|
-
if (done) break
|
|
365
|
-
this.stderrBuffer = boundedAppend(this.stderrBuffer, decoder.decode(value, { stream: true }))
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
private handleLine(line: string): void {
|
|
370
|
-
let message: { id?: unknown; output?: unknown }
|
|
371
|
-
try {
|
|
372
|
-
message = JSON.parse(line) as { id?: unknown; output?: unknown }
|
|
373
|
-
} catch {
|
|
374
|
-
this.stderrBuffer = boundedAppend(
|
|
375
|
-
this.stderrBuffer,
|
|
376
|
-
`invalid warm ${this.label} worker line: ${line}\n`,
|
|
377
|
-
)
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
if (typeof message.id !== "number") return
|
|
381
|
-
const pending = this.pending.get(message.id)
|
|
382
|
-
if (pending === undefined) return
|
|
383
|
-
this.pending.delete(message.id)
|
|
384
|
-
pending.cleanup()
|
|
385
|
-
pending.resolve(JSON.stringify(message.output, null, 2))
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
|
|
390
|
-
* subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
|
|
391
|
-
* `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
|
|
392
|
-
* message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
|
|
393
|
-
function createWarmHandler(
|
|
394
|
-
child: "mcp-run" | "mcp-render",
|
|
395
|
-
cwd: string,
|
|
396
|
-
label: string,
|
|
397
|
-
): (input: unknown, signal?: AbortSignal) => Promise<string> {
|
|
398
|
-
let worker: WarmWorker | undefined
|
|
399
|
-
return async (input, signal) => {
|
|
400
|
-
const fingerprint = await warmRunFingerprint(cwd)
|
|
401
|
-
if (worker === undefined || worker.fingerprint !== fingerprint) {
|
|
402
|
-
worker?.stop()
|
|
403
|
-
worker = new WarmWorker(child, cwd, fingerprint, label)
|
|
404
|
-
}
|
|
405
|
-
try {
|
|
406
|
-
return await worker.request(input, signal)
|
|
407
|
-
} catch {
|
|
408
|
-
worker.stop()
|
|
409
|
-
worker = undefined
|
|
410
|
-
if (signal?.aborted) {
|
|
411
|
-
const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
|
|
412
|
-
return `${label} cancelled${reason}.`
|
|
413
|
-
}
|
|
414
|
-
return spawnChild(child, cwd, input, label, signal)
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** The `nifra_run` handler: run requests through the project's CURRENT backend, return structured results. */
|
|
420
|
-
async function runHandler(
|
|
421
|
-
cwd: string,
|
|
422
|
-
args: Record<string, unknown>,
|
|
423
|
-
context: McpToolContext,
|
|
424
|
-
warmRun: (input: unknown, signal?: AbortSignal) => Promise<string>,
|
|
425
|
-
): Promise<string> {
|
|
426
|
-
const requests = Array.isArray(args.requests) ? args.requests : []
|
|
427
|
-
if (requests.length === 0) {
|
|
428
|
-
return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
|
|
429
|
-
}
|
|
430
|
-
const input = { requests, entry: args.entry }
|
|
431
|
-
if ((args as { warm?: boolean }).warm === true) {
|
|
432
|
-
context.reportProgress(0.2, 1)
|
|
433
|
-
return warmRun(input, context.signal)
|
|
434
|
-
}
|
|
435
|
-
return spawnChild("mcp-run", cwd, input, "run", context.signal)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** The `nifra_render` handler: SSR page routes through the project's CURRENT web app, return the HTML.
|
|
439
|
-
* By default a fresh subprocess loads the current code each call; `warm:true` reuses a hot worker (like
|
|
440
|
-
* `nifra_run`) that auto-restarts when a source file changes. */
|
|
441
|
-
async function renderHandler(
|
|
442
|
-
cwd: string,
|
|
443
|
-
args: Record<string, unknown>,
|
|
444
|
-
context: McpToolContext,
|
|
445
|
-
warmRender: (input: unknown, signal?: AbortSignal) => Promise<string>,
|
|
446
|
-
): Promise<string> {
|
|
447
|
-
const requests = Array.isArray(args.requests) ? args.requests : []
|
|
448
|
-
if (requests.length === 0) {
|
|
449
|
-
return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
|
|
450
|
-
}
|
|
451
|
-
const input = { requests }
|
|
452
|
-
if ((args as { warm?: boolean }).warm === true) {
|
|
453
|
-
context.reportProgress(0.2, 1)
|
|
454
|
-
return warmRender(input, context.signal)
|
|
455
|
-
}
|
|
456
|
-
return spawnChild("mcp-render", cwd, input, "render", context.signal)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** The `nifra_ws` handler: verify WebSocket routes through a fresh app subprocess. */
|
|
460
|
-
async function wsHandler(
|
|
461
|
-
cwd: string,
|
|
462
|
-
args: Record<string, unknown>,
|
|
463
|
-
context: McpToolContext,
|
|
464
|
-
): Promise<string> {
|
|
465
|
-
return spawnChild("mcp-ws", cwd, args, "websocket", context.signal)
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
|
|
469
|
-
const format = args.format
|
|
470
|
-
return format === "yaml" ? "yaml" : "json"
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
type LoadAppForCache = (
|
|
474
|
-
cwd: string,
|
|
475
|
-
outDirName?: string,
|
|
476
|
-
options?: LoadAppOptions,
|
|
477
|
-
) => Promise<LoadedApp>
|
|
478
|
-
|
|
479
|
-
export interface CachedAppLoaderOptions {
|
|
480
|
-
readonly loadApp?: LoadAppForCache
|
|
481
|
-
readonly fingerprint?: (cwd: string) => Promise<string>
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const APP_FINGERPRINT_FILES = ["nifra.config.ts", "framework.ts", "backend.ts"] as const
|
|
485
|
-
|
|
486
|
-
function cacheToken(input: string): string {
|
|
487
|
-
let hash = 2166136261
|
|
488
|
-
for (let i = 0; i < input.length; i++) {
|
|
489
|
-
hash ^= input.charCodeAt(i)
|
|
490
|
-
hash = Math.imul(hash, 16777619)
|
|
491
|
-
}
|
|
492
|
-
return `mcp=${(hash >>> 0).toString(36)}`
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function fileFingerprint(path: string): Promise<string> {
|
|
496
|
-
try {
|
|
497
|
-
const s = await stat(path)
|
|
498
|
-
return `${s.mtimeMs}:${s.size}`
|
|
499
|
-
} catch (err) {
|
|
500
|
-
if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
|
|
501
|
-
return "missing"
|
|
502
|
-
}
|
|
503
|
-
throw err
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
async function appFingerprint(cwd: string): Promise<string> {
|
|
508
|
-
return (
|
|
509
|
-
await Promise.all(
|
|
510
|
-
APP_FINGERPRINT_FILES.map(
|
|
511
|
-
async (file) => `${file}:${await fileFingerprint(resolve(cwd, file))}`,
|
|
512
|
-
),
|
|
513
|
-
)
|
|
514
|
-
).join("|")
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** Cache LoadedApp inside one MCP server process, invalidating when config/backend mtimes change. */
|
|
518
|
-
export function createCachedAppLoader(
|
|
519
|
-
cwd: string,
|
|
520
|
-
options: CachedAppLoaderOptions = {},
|
|
521
|
-
): (outDirName?: string) => Promise<LoadedApp> {
|
|
522
|
-
const loadAppCached =
|
|
523
|
-
options.loadApp ??
|
|
524
|
-
(async (root: string, outDirName?: string, loadOptions?: LoadAppOptions) => {
|
|
525
|
-
const mod = await import("./load.ts")
|
|
526
|
-
return mod.loadApp(root, outDirName, loadOptions)
|
|
527
|
-
})
|
|
528
|
-
const fingerprint = options.fingerprint ?? appFingerprint
|
|
529
|
-
let cached:
|
|
530
|
-
| {
|
|
531
|
-
readonly outDirName: string
|
|
532
|
-
readonly fingerprint: string
|
|
533
|
-
readonly app: LoadedApp
|
|
534
|
-
}
|
|
535
|
-
| undefined
|
|
536
|
-
|
|
537
|
-
return async (outDirName = "dist") => {
|
|
538
|
-
const nextFingerprint = await fingerprint(cwd)
|
|
539
|
-
if (
|
|
540
|
-
cached !== undefined &&
|
|
541
|
-
cached.outDirName === outDirName &&
|
|
542
|
-
cached.fingerprint === nextFingerprint
|
|
543
|
-
) {
|
|
544
|
-
return cached.app
|
|
545
|
-
}
|
|
546
|
-
const app = await loadAppCached(cwd, outDirName, {
|
|
547
|
-
importQuery: cacheToken(nextFingerprint),
|
|
548
|
-
})
|
|
549
|
-
cached = { outDirName, fingerprint: nextFingerprint, app }
|
|
550
|
-
return app
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
async function openApiHandler(
|
|
555
|
-
args: Record<string, unknown>,
|
|
556
|
-
loadAppCached: (outDirName?: string) => Promise<LoadedApp>,
|
|
557
|
-
): Promise<string> {
|
|
558
|
-
const { renderOpenApi } = await import("./openapi-tool.ts")
|
|
559
|
-
const pathPrefix = typeof args.path === "string" ? args.path : undefined
|
|
560
|
-
return renderOpenApi(await loadAppCached(), openApiFormat(args), pathPrefix)
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
async function readProjectFile(
|
|
564
|
-
cwd: string,
|
|
565
|
-
relativeFile: string,
|
|
566
|
-
maxChars: number,
|
|
567
|
-
): Promise<string> {
|
|
568
|
-
const target = resolve(cwd, relativeFile)
|
|
569
|
-
const root = resolve(cwd)
|
|
570
|
-
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
571
|
-
throw new Error(`refusing to read outside project root: ${relativeFile}`)
|
|
572
|
-
}
|
|
573
|
-
try {
|
|
574
|
-
const text = await readFile(target, "utf8")
|
|
575
|
-
return text.length <= maxChars
|
|
576
|
-
? text
|
|
577
|
-
: `${text.slice(0, maxChars)}\n…(trimmed; read ${relativeFile} directly for the rest)`
|
|
578
|
-
} catch (err) {
|
|
579
|
-
if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
|
|
580
|
-
return `No ${relativeFile} found in ${basename(cwd)}.`
|
|
581
|
-
}
|
|
582
|
-
throw err
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function promptText(
|
|
587
|
-
text: string,
|
|
588
|
-
): readonly [
|
|
589
|
-
{ readonly role: "user"; readonly content: { readonly type: "text"; readonly text: string } },
|
|
590
|
-
] {
|
|
591
|
-
return [{ role: "user", content: { type: "text", text } }]
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function projectResources(
|
|
595
|
-
cwd: string,
|
|
596
|
-
loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
597
|
-
): McpResource[] {
|
|
598
|
-
return [
|
|
599
|
-
{
|
|
600
|
-
uri: "nifra://routes",
|
|
601
|
-
name: "API routes",
|
|
602
|
-
description:
|
|
603
|
-
"Structured API routes with typed-client calls and compact request/response shapes.",
|
|
604
|
-
mimeType: "application/json",
|
|
605
|
-
read: async () => {
|
|
606
|
-
const { routesToJson } = await import("./introspect.ts")
|
|
607
|
-
return { text: JSON.stringify(routesToJson(await loadAppCached()), null, 2) }
|
|
608
|
-
},
|
|
609
|
-
},
|
|
610
|
-
{
|
|
611
|
-
uri: "nifra://openapi",
|
|
612
|
-
name: "OpenAPI 3.1",
|
|
613
|
-
description: "OpenAPI document generated from backend.ts using @nifrajs/schema.",
|
|
614
|
-
mimeType: "application/json",
|
|
615
|
-
read: async () => ({ text: await openApiHandler({ format: "json" }, loadAppCached) }),
|
|
616
|
-
},
|
|
617
|
-
{
|
|
618
|
-
uri: "nifra://package-json",
|
|
619
|
-
name: "package.json",
|
|
620
|
-
description: "Project package metadata and scripts.",
|
|
621
|
-
mimeType: "application/json",
|
|
622
|
-
read: async () => ({ text: await readProjectFile(cwd, "package.json", 40_000) }),
|
|
623
|
-
},
|
|
624
|
-
{
|
|
625
|
-
uri: "nifra://agents-md",
|
|
626
|
-
name: "AGENTS.md",
|
|
627
|
-
description: "Repository-specific agent instructions if the project has them.",
|
|
628
|
-
mimeType: "text/markdown",
|
|
629
|
-
read: async () => ({ text: await readProjectFile(cwd, "AGENTS.md", 40_000) }),
|
|
630
|
-
},
|
|
631
|
-
]
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
export function projectPrompts(): McpPrompt[] {
|
|
635
|
-
return [
|
|
636
|
-
{
|
|
637
|
-
name: "nifra_new_route",
|
|
638
|
-
description:
|
|
639
|
-
"Implement a new file route with the right routes/ filename, examples, and checks.",
|
|
640
|
-
arguments: [
|
|
641
|
-
{ name: "path", description: 'URL path, e.g. "/users/:id".', required: true },
|
|
642
|
-
{ name: "goal", description: "What the page should do.", required: false },
|
|
643
|
-
],
|
|
644
|
-
handler: async (args) =>
|
|
645
|
-
promptText(
|
|
646
|
-
[
|
|
647
|
-
`Create a nifra page route for ${JSON.stringify(args.path ?? "/new-route")}.`,
|
|
648
|
-
args.goal ? `Goal: ${String(args.goal)}` : undefined,
|
|
649
|
-
'Use `nifra_context` with `{ kind: "pages" }`, then `nifra_scaffold` for the exact file path.',
|
|
650
|
-
"If the stub is not writable for this framework, call `nifra_example` for a verified page/loader example before editing.",
|
|
651
|
-
"Verify with `nifra_render` for the route and finish with `nifra_check`.",
|
|
652
|
-
]
|
|
653
|
-
.filter(Boolean)
|
|
654
|
-
.join("\n"),
|
|
655
|
-
),
|
|
656
|
-
},
|
|
657
|
-
{
|
|
658
|
-
name: "nifra_add_endpoint",
|
|
659
|
-
description:
|
|
660
|
-
"Add or update a backend endpoint with schemas, typed-client usage, and verification.",
|
|
661
|
-
arguments: [
|
|
662
|
-
{ name: "method", description: "HTTP method.", required: true },
|
|
663
|
-
{ name: "path", description: 'API path, e.g. "/users/:id".', required: true },
|
|
664
|
-
{ name: "goal", description: "What the endpoint should do.", required: false },
|
|
665
|
-
],
|
|
666
|
-
handler: async (args) =>
|
|
667
|
-
promptText(
|
|
668
|
-
[
|
|
669
|
-
`Add a nifra backend endpoint: ${String(args.method ?? "GET").toUpperCase()} ${String(args.path ?? "/api")}.`,
|
|
670
|
-
args.goal ? `Goal: ${String(args.goal)}` : undefined,
|
|
671
|
-
"Read `nifra://routes` or call `nifra_routes` first so the new route fits the existing API shape.",
|
|
672
|
-
"Use route schemas for untrusted body/query input and declare `response` when the frontend consumes the shape.",
|
|
673
|
-
"Verify behavior with `nifra_run`, then run `nifra_check` and `nifra_test` for the touched area.",
|
|
674
|
-
]
|
|
675
|
-
.filter(Boolean)
|
|
676
|
-
.join("\n"),
|
|
677
|
-
),
|
|
678
|
-
},
|
|
679
|
-
{
|
|
680
|
-
name: "nifra_debug_drift",
|
|
681
|
-
description:
|
|
682
|
-
"Debug frontend/backend contract drift using the typed client and checker output.",
|
|
683
|
-
arguments: [
|
|
684
|
-
{ name: "symptom", description: "The error or behavior to investigate.", required: false },
|
|
685
|
-
],
|
|
686
|
-
handler: async (args) =>
|
|
687
|
-
promptText(
|
|
688
|
-
[
|
|
689
|
-
"Debug nifra contract drift.",
|
|
690
|
-
args.symptom ? `Symptom: ${String(args.symptom)}` : undefined,
|
|
691
|
-
"Start with `nifra_check`; use each diagnostic as the source of truth.",
|
|
692
|
-
"Read `nifra://routes` for the exact typed-client calls and compact body/query/response shapes.",
|
|
693
|
-
"Prefer `client<typeof app>` over hand-rolled internal `fetch`. Verify fixed endpoints with `nifra_run`.",
|
|
694
|
-
]
|
|
695
|
-
.filter(Boolean)
|
|
696
|
-
.join("\n"),
|
|
697
|
-
),
|
|
698
|
-
},
|
|
699
|
-
]
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
export function projectFeatures(
|
|
703
|
-
cwd: string,
|
|
704
|
-
loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
705
|
-
): McpServerFeatures {
|
|
706
|
-
return { resources: projectResources(cwd, loadAppCached), prompts: projectPrompts() }
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Resolve an optional `dir` tool argument - a subdirectory of the project root the caller wants to scope a
|
|
711
|
-
* check/test to (e.g. `nifra check` on `app/` when the MCP server's root is a monorepo). Returns the
|
|
712
|
-
* absolute target, or `null` if `dir` escapes the root (a path-traversal guard: no `..` out, no absolute
|
|
713
|
-
* path elsewhere). `undefined`/empty → the root itself.
|
|
714
|
-
*/
|
|
715
|
-
export function resolveProjectDir(root: string, dir: string | undefined): string | null {
|
|
716
|
-
if (dir === undefined || dir === "") return root
|
|
717
|
-
const target = resolve(root, dir)
|
|
718
|
-
let canonicalRoot: string
|
|
719
|
-
try {
|
|
720
|
-
canonicalRoot = realpathSync(root)
|
|
721
|
-
} catch {
|
|
722
|
-
const lexical = relative(root, target)
|
|
723
|
-
return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null
|
|
724
|
-
}
|
|
725
|
-
let probe = target
|
|
726
|
-
const missing: string[] = []
|
|
727
|
-
while (true) {
|
|
728
|
-
try {
|
|
729
|
-
const canonicalProbe = realpathSync(probe)
|
|
730
|
-
const rel = relative(canonicalRoot, canonicalProbe)
|
|
731
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return null
|
|
732
|
-
return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse())
|
|
733
|
-
} catch {
|
|
734
|
-
const parent = resolve(probe, "..")
|
|
735
|
-
if (parent === probe) return null
|
|
736
|
-
// `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
|
|
737
|
-
// separator is part of it, so `parent.length + 1` eats the first character of the segment and
|
|
738
|
-
// the path gets rebuilt wrong.
|
|
739
|
-
missing.push(basename(probe))
|
|
740
|
-
probe = parent
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/** Consistent error string for a `dir` that escapes the project root. */
|
|
746
|
-
function dirError(dir: string | undefined): string {
|
|
747
|
-
return JSON.stringify(
|
|
748
|
-
{ ok: false, error: `dir must be a subdirectory of the project root - "${dir}" escapes it.` },
|
|
749
|
-
null,
|
|
750
|
-
2,
|
|
751
|
-
)
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/** Build the project-scoped tools for `cwd`. */
|
|
755
|
-
export interface CommandMcpToolOptions {
|
|
756
|
-
readonly cwd: string
|
|
757
|
-
readonly loadAppCached?: () => Promise<LoadedApp>
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/** Adapt one executable catalog spec to its MCP descriptor and project-scoped handler. */
|
|
761
|
-
export function toMcpTool(
|
|
762
|
-
source: CommandSpec<unknown, unknown> | CommandCatalogEntry,
|
|
763
|
-
options: CommandMcpToolOptions,
|
|
764
|
-
): McpTool {
|
|
765
|
-
const spec = "run" in source ? source : findCommandSpec(source.name)
|
|
766
|
-
if (spec === undefined) throw new Error(`missing command spec for ${source.name}`)
|
|
767
|
-
const entry = "run" in source ? undefined : source
|
|
768
|
-
const { cwd } = options
|
|
769
|
-
const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
|
|
770
|
-
return {
|
|
771
|
-
name: commandMcpName(entry?.name ?? spec.name),
|
|
772
|
-
description: entry?.summary ?? spec.summary,
|
|
773
|
-
inputSchema: entry?.inputSchema ?? spec.input.jsonSchema,
|
|
774
|
-
handler: async (args: Record<string, unknown>, context: McpToolContext) => {
|
|
775
|
-
const raw = { ...args }
|
|
776
|
-
const dir = raw.dir
|
|
777
|
-
if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
|
|
778
|
-
const target = resolveProjectDir(cwd, dir as string | undefined)
|
|
779
|
-
if (target === null) return dirError(dir as string | undefined)
|
|
780
|
-
if (typeof raw.config === "string") {
|
|
781
|
-
const config = resolve(target, raw.config)
|
|
782
|
-
if (config !== target && !config.startsWith(`${target}${sep}`))
|
|
783
|
-
return JSON.stringify(
|
|
784
|
-
{ ok: false, error: "config must stay inside the selected project directory" },
|
|
785
|
-
null,
|
|
786
|
-
2,
|
|
787
|
-
)
|
|
788
|
-
}
|
|
789
|
-
delete raw.dir
|
|
790
|
-
let input: unknown
|
|
791
|
-
try {
|
|
792
|
-
input = spec.input.parse(raw)
|
|
793
|
-
} catch (error) {
|
|
794
|
-
return JSON.stringify(
|
|
795
|
-
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
796
|
-
null,
|
|
797
|
-
2,
|
|
798
|
-
)
|
|
799
|
-
}
|
|
800
|
-
const commandContext: CommandCtx = {
|
|
801
|
-
cwd: target,
|
|
802
|
-
signal: context.signal,
|
|
803
|
-
progress: () => context.reportProgress?.(0.5, 1),
|
|
804
|
-
...(target === cwd ? { loadApp: loadAppCached } : {}),
|
|
805
|
-
}
|
|
806
|
-
const output = await spec.run(input, commandContext)
|
|
807
|
-
const value = spec.json?.(output, input) ?? output
|
|
808
|
-
return typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
809
|
-
},
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
export function catalogProjectTools(
|
|
814
|
-
cwd: string,
|
|
815
|
-
loadAppCached: () => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
816
|
-
): McpTool[] {
|
|
817
|
-
return commandCatalog
|
|
818
|
-
.filter((entry) => entry.transports.includes("mcp"))
|
|
819
|
-
.map((entry) => {
|
|
820
|
-
const spec = findCommandSpec(entry.name)
|
|
821
|
-
if (spec === undefined) throw new Error(`missing command spec for ${entry.name}`)
|
|
822
|
-
return toMcpTool(spec, { cwd, loadAppCached })
|
|
823
|
-
})
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
export function projectTools(
|
|
827
|
-
cwd: string,
|
|
828
|
-
loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
|
|
829
|
-
): McpTool[] {
|
|
830
|
-
const warmRun = createWarmHandler("mcp-run", cwd, "run")
|
|
831
|
-
const warmRender = createWarmHandler("mcp-render", cwd, "render")
|
|
832
|
-
const catalogTools = catalogProjectTools(cwd, () => loadAppCached())
|
|
833
|
-
const legacyTools: McpTool[] = [
|
|
834
|
-
{
|
|
835
|
-
name: "nifra_contract_proof",
|
|
836
|
-
description:
|
|
837
|
-
"Compare the current route contract with its snapshot and join each changed route to its route-assurance and capability evidence. Returns hasBreaking as the gate. The typed-contract check is lazy and runs only when check is true.",
|
|
838
|
-
inputSchema: {
|
|
839
|
-
type: "object",
|
|
840
|
-
properties: {
|
|
841
|
-
baseline: {
|
|
842
|
-
type: "string",
|
|
843
|
-
description:
|
|
844
|
-
"Snapshot path relative to the selected project directory. Defaults to api-snapshot.json.",
|
|
845
|
-
},
|
|
846
|
-
check: {
|
|
847
|
-
type: "boolean",
|
|
848
|
-
description: "Also run the typed-contract check. Default false to keep the diff lazy.",
|
|
849
|
-
},
|
|
850
|
-
dir: {
|
|
851
|
-
type: "string",
|
|
852
|
-
description: "Project subdirectory to inspect.",
|
|
853
|
-
},
|
|
854
|
-
},
|
|
855
|
-
additionalProperties: false,
|
|
856
|
-
},
|
|
857
|
-
handler: async (args) => {
|
|
858
|
-
const raw = args as { baseline?: unknown; check?: unknown; dir?: unknown }
|
|
859
|
-
if (raw.dir !== undefined && typeof raw.dir !== "string") {
|
|
860
|
-
return JSON.stringify({ ok: false, error: "dir must be a string" }, null, 2)
|
|
861
|
-
}
|
|
862
|
-
if (raw.baseline !== undefined && typeof raw.baseline !== "string") {
|
|
863
|
-
return JSON.stringify({ ok: false, error: "baseline must be a string" }, null, 2)
|
|
864
|
-
}
|
|
865
|
-
const dir = raw.dir as string | undefined
|
|
866
|
-
const baseline = raw.baseline as string | undefined
|
|
867
|
-
const target = resolveProjectDir(cwd, dir)
|
|
868
|
-
if (target === null) return dirError(dir)
|
|
869
|
-
if (baseline !== undefined && baseline.trim() === "") {
|
|
870
|
-
return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
|
|
871
|
-
}
|
|
872
|
-
try {
|
|
873
|
-
return JSON.stringify(
|
|
874
|
-
await collectContractProof(target, {
|
|
875
|
-
...(baseline === undefined ? {} : { baselinePath: baseline }),
|
|
876
|
-
...(raw.check === undefined ? {} : { check: raw.check === true }),
|
|
877
|
-
}),
|
|
878
|
-
null,
|
|
879
|
-
2,
|
|
880
|
-
)
|
|
881
|
-
} catch (error) {
|
|
882
|
-
return JSON.stringify(
|
|
883
|
-
{ ok: false, error: error instanceof Error ? error.message : String(error) },
|
|
884
|
-
null,
|
|
885
|
-
2,
|
|
886
|
-
)
|
|
887
|
-
}
|
|
888
|
-
},
|
|
889
|
-
},
|
|
890
|
-
{
|
|
891
|
-
name: "nifra_explain",
|
|
892
|
-
description:
|
|
893
|
-
"Turn a nifra error into a STRUCTURED diagnostic instead of eyeballing a stack trace: a stable `code`, the top frame in YOUR source, a codeframe around the offending line, and - when nifra recognises the failure - the plain-language `cause` + `fix` + docs anchor. Pass `error` (and `stack` if you have it, e.g. from nifra_run/nifra_test output or a failing build) to explain a specific failure; or pass `port` to fetch the running dev server's most recent SSR failure from `/__nifra/last-error`. Returns the same JSON the dev overlay renders.",
|
|
894
|
-
inputSchema: {
|
|
895
|
-
type: "object",
|
|
896
|
-
properties: {
|
|
897
|
-
error: {
|
|
898
|
-
type: "string",
|
|
899
|
-
description:
|
|
900
|
-
"The error message to explain (copy it from nifra_run/nifra_test output or a failing build).",
|
|
901
|
-
},
|
|
902
|
-
stack: {
|
|
903
|
-
type: "string",
|
|
904
|
-
description:
|
|
905
|
-
"The error's stack trace, if you have it - enables the source codeframe and the top user frame.",
|
|
906
|
-
},
|
|
907
|
-
name: {
|
|
908
|
-
type: "string",
|
|
909
|
-
description:
|
|
910
|
-
"The error's class name (e.g. TypeError, SchemaError), if known - sharpens classification.",
|
|
911
|
-
},
|
|
912
|
-
port: {
|
|
913
|
-
type: "number",
|
|
914
|
-
description:
|
|
915
|
-
"Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
|
|
916
|
-
},
|
|
917
|
-
},
|
|
918
|
-
additionalProperties: false,
|
|
919
|
-
},
|
|
920
|
-
handler: async (args) => {
|
|
921
|
-
const { error, stack, name, port } = args as {
|
|
922
|
-
error?: string
|
|
923
|
-
stack?: string
|
|
924
|
-
name?: string
|
|
925
|
-
port?: number
|
|
926
|
-
}
|
|
927
|
-
if (error !== undefined || stack !== undefined) {
|
|
928
|
-
const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
|
|
929
|
-
const e = new Error(error ?? "")
|
|
930
|
-
if (name !== undefined) e.name = name
|
|
931
|
-
if (stack !== undefined) e.stack = stack
|
|
932
|
-
return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
|
|
933
|
-
}
|
|
934
|
-
if (port !== undefined) {
|
|
935
|
-
const validPort = validateLocalPort(port)
|
|
936
|
-
if (validPort === undefined) {
|
|
937
|
-
return JSON.stringify(
|
|
938
|
-
{ code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
|
|
939
|
-
null,
|
|
940
|
-
2,
|
|
941
|
-
)
|
|
942
|
-
}
|
|
943
|
-
const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
|
|
944
|
-
try {
|
|
945
|
-
const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
|
|
946
|
-
signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
|
|
947
|
-
})
|
|
948
|
-
if (res.headers.get("x-nifra-diagnostic") !== "true")
|
|
949
|
-
return notNifraResponse("diagnostic")
|
|
950
|
-
if (!res.ok) {
|
|
951
|
-
return JSON.stringify(
|
|
952
|
-
{
|
|
953
|
-
code: "NIFRA_NONE",
|
|
954
|
-
message: `dev server at :${validPort} returned ${res.status}`,
|
|
955
|
-
},
|
|
956
|
-
null,
|
|
957
|
-
2,
|
|
958
|
-
)
|
|
959
|
-
}
|
|
960
|
-
return await readBoundedResponse(res)
|
|
961
|
-
} catch (cause) {
|
|
962
|
-
return JSON.stringify(
|
|
963
|
-
{
|
|
964
|
-
code: "NIFRA_NONE",
|
|
965
|
-
message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
966
|
-
},
|
|
967
|
-
null,
|
|
968
|
-
2,
|
|
969
|
-
)
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
return JSON.stringify(
|
|
973
|
-
{
|
|
974
|
-
code: "NIFRA_NONE",
|
|
975
|
-
message:
|
|
976
|
-
"Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
|
|
977
|
-
},
|
|
978
|
-
null,
|
|
979
|
-
2,
|
|
980
|
-
)
|
|
981
|
-
},
|
|
982
|
-
},
|
|
983
|
-
{
|
|
984
|
-
name: "nifra_inspect",
|
|
985
|
-
description:
|
|
986
|
-
"Observe what your requests ACTUALLY did on the running dev server - the recent request traces the DevTools plugin records: `{ method, path, status, durationMs, isrStatus, bodyBytes }` per request. The read no other tool gives you: after nifra_run or a real browser request, call this to SEE the outcome (which route answered, the status, how long, ISR hit/miss) instead of guessing. Pass `port` (the running dev server); narrow with `path` (a path prefix) or `limit` (most recent N). Requires the app to mount `@nifrajs/web`'s `devtools()` plugin (which auto-enables in development).",
|
|
987
|
-
inputSchema: {
|
|
988
|
-
type: "object",
|
|
989
|
-
properties: {
|
|
990
|
-
port: { type: "number", description: "The running dev server's port." },
|
|
991
|
-
path: { type: "string", description: "Only traces whose path starts with this prefix." },
|
|
992
|
-
limit: { type: "number", description: "Return only the most recent N traces." },
|
|
993
|
-
},
|
|
994
|
-
required: ["port"],
|
|
995
|
-
additionalProperties: false,
|
|
996
|
-
},
|
|
997
|
-
handler: async (args) => {
|
|
998
|
-
const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
|
|
999
|
-
if (port === undefined) {
|
|
1000
|
-
return JSON.stringify(
|
|
1001
|
-
{
|
|
1002
|
-
events: [],
|
|
1003
|
-
note: "Pass `port` - the running dev server whose request traces to read.",
|
|
1004
|
-
},
|
|
1005
|
-
null,
|
|
1006
|
-
2,
|
|
1007
|
-
)
|
|
1008
|
-
}
|
|
1009
|
-
try {
|
|
1010
|
-
const validPort = validateLocalPort(port)
|
|
1011
|
-
if (validPort === undefined) {
|
|
1012
|
-
return JSON.stringify(
|
|
1013
|
-
{
|
|
1014
|
-
events: [],
|
|
1015
|
-
code: "NIFRA_INVALID_PORT",
|
|
1016
|
-
note: "port must be an integer from 1 to 65535.",
|
|
1017
|
-
},
|
|
1018
|
-
null,
|
|
1019
|
-
2,
|
|
1020
|
-
)
|
|
1021
|
-
}
|
|
1022
|
-
const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
|
|
1023
|
-
if (path !== undefined) url.searchParams.set("path", path)
|
|
1024
|
-
if (limit !== undefined) url.searchParams.set("limit", String(limit))
|
|
1025
|
-
const res = await fetch(url, {
|
|
1026
|
-
signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
|
|
1027
|
-
})
|
|
1028
|
-
if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
|
|
1029
|
-
if (res.status === 404) {
|
|
1030
|
-
return JSON.stringify(
|
|
1031
|
-
{
|
|
1032
|
-
events: [],
|
|
1033
|
-
note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
|
|
1034
|
-
},
|
|
1035
|
-
null,
|
|
1036
|
-
2,
|
|
1037
|
-
)
|
|
1038
|
-
}
|
|
1039
|
-
if (!res.ok) {
|
|
1040
|
-
return JSON.stringify(
|
|
1041
|
-
{ events: [], note: `DevTools state returned ${res.status}.` },
|
|
1042
|
-
null,
|
|
1043
|
-
2,
|
|
1044
|
-
)
|
|
1045
|
-
}
|
|
1046
|
-
return await readBoundedResponse(res)
|
|
1047
|
-
} catch (cause) {
|
|
1048
|
-
const validPort = validateLocalPort(port)
|
|
1049
|
-
return JSON.stringify(
|
|
1050
|
-
{
|
|
1051
|
-
events: [],
|
|
1052
|
-
note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1053
|
-
},
|
|
1054
|
-
null,
|
|
1055
|
-
2,
|
|
1056
|
-
)
|
|
1057
|
-
}
|
|
1058
|
-
},
|
|
1059
|
-
},
|
|
1060
|
-
{
|
|
1061
|
-
name: "nifra_openapi",
|
|
1062
|
-
description:
|
|
1063
|
-
'Return this project\'s backend OpenAPI 3.1 document generated from backend.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Pass `path` (a route prefix like /api/orders, mirroring nifra_routes) to narrow a large backend to operations under that prefix instead of the whole document. Frontend-only apps return a valid empty paths object.',
|
|
1064
|
-
inputSchema: {
|
|
1065
|
-
type: "object",
|
|
1066
|
-
properties: {
|
|
1067
|
-
format: {
|
|
1068
|
-
type: "string",
|
|
1069
|
-
enum: ["json", "yaml"],
|
|
1070
|
-
description: "Output format (default: json).",
|
|
1071
|
-
},
|
|
1072
|
-
path: {
|
|
1073
|
-
type: "string",
|
|
1074
|
-
description:
|
|
1075
|
-
"Only operations whose path starts with this prefix (omit for the whole document).",
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
additionalProperties: false,
|
|
1079
|
-
},
|
|
1080
|
-
handler: (args) => openApiHandler(args, loadAppCached),
|
|
1081
|
-
},
|
|
1082
|
-
{
|
|
1083
|
-
name: "nifra_run",
|
|
1084
|
-
description:
|
|
1085
|
-
"Run HTTP requests through this project's backend and return structured results (status, headers, parsed body, and any thrown error). Use it to verify code after editing: by default the backend is re-loaded in a fresh process each call. Pass warm:true to reuse a hot worker while source files are unchanged; it restarts automatically when files change. Each request: { method?, path, body?, headers? }.",
|
|
1086
|
-
inputSchema: {
|
|
1087
|
-
type: "object",
|
|
1088
|
-
properties: {
|
|
1089
|
-
requests: {
|
|
1090
|
-
type: "array",
|
|
1091
|
-
items: {
|
|
1092
|
-
type: "object",
|
|
1093
|
-
properties: {
|
|
1094
|
-
method: { type: "string" },
|
|
1095
|
-
path: { type: "string" },
|
|
1096
|
-
body: {},
|
|
1097
|
-
headers: { type: "object" },
|
|
1098
|
-
},
|
|
1099
|
-
required: ["path"],
|
|
1100
|
-
},
|
|
1101
|
-
},
|
|
1102
|
-
entry: {
|
|
1103
|
-
type: "string",
|
|
1104
|
-
description: "Backend entry file (default: backend.ts | app.ts).",
|
|
1105
|
-
},
|
|
1106
|
-
warm: {
|
|
1107
|
-
type: "boolean",
|
|
1108
|
-
description:
|
|
1109
|
-
"Reuse a hot backend worker while source files are unchanged. Default false for maximum isolation.",
|
|
1110
|
-
},
|
|
1111
|
-
},
|
|
1112
|
-
required: ["requests"],
|
|
1113
|
-
additionalProperties: false,
|
|
1114
|
-
},
|
|
1115
|
-
handler: (args, context) => runHandler(cwd, args, context, warmRun),
|
|
1116
|
-
},
|
|
1117
|
-
{
|
|
1118
|
-
name: "nifra_render",
|
|
1119
|
-
description:
|
|
1120
|
-
"SSR a page route (routes/) through this project's CURRENT web app and return { status, headers, body: the rendered HTML }. The page half of nifra_run: use it to verify a page renders and its loader ran after an edit. No build needed (a placeholder client entry is used; the SSR HTML renders regardless). Each request: { path, headers? }. By default re-loaded in a fresh process each call; pass warm:true to reuse a hot worker while source files are unchanged (it restarts automatically when files change), mirroring nifra_run.",
|
|
1121
|
-
inputSchema: {
|
|
1122
|
-
type: "object",
|
|
1123
|
-
properties: {
|
|
1124
|
-
requests: {
|
|
1125
|
-
type: "array",
|
|
1126
|
-
items: {
|
|
1127
|
-
type: "object",
|
|
1128
|
-
properties: {
|
|
1129
|
-
path: { type: "string" },
|
|
1130
|
-
headers: { type: "object" },
|
|
1131
|
-
},
|
|
1132
|
-
required: ["path"],
|
|
1133
|
-
},
|
|
1134
|
-
},
|
|
1135
|
-
warm: {
|
|
1136
|
-
type: "boolean",
|
|
1137
|
-
description:
|
|
1138
|
-
"Reuse a hot web-app worker while source files are unchanged. Default false for maximum isolation.",
|
|
1139
|
-
},
|
|
1140
|
-
},
|
|
1141
|
-
required: ["requests"],
|
|
1142
|
-
additionalProperties: false,
|
|
1143
|
-
},
|
|
1144
|
-
handler: (args, context) => renderHandler(cwd, args, context, warmRender),
|
|
1145
|
-
},
|
|
1146
|
-
{
|
|
1147
|
-
name: "nifra_ws",
|
|
1148
|
-
description:
|
|
1149
|
-
'Verify a WebSocket route against this project by starting the backend on an ephemeral localhost port, opening a real Bun WebSocket, sending string frames, and returning structured evidence: { ok, opened, sent, received, close?, error? }. Use after adding or editing app.ws() routes. Pass path including query, e.g. "/chat?token=secret". By default expects one message when no messages are sent, or one response per sent message; set expectMessages:0 to verify connect-only routes.',
|
|
1150
|
-
inputSchema: {
|
|
1151
|
-
type: "object",
|
|
1152
|
-
properties: {
|
|
1153
|
-
path: {
|
|
1154
|
-
type: "string",
|
|
1155
|
-
description:
|
|
1156
|
-
'App-local WebSocket path, optionally with query, e.g. "/chat?token=secret".',
|
|
1157
|
-
},
|
|
1158
|
-
messages: {
|
|
1159
|
-
type: "array",
|
|
1160
|
-
items: { type: "string" },
|
|
1161
|
-
description: "String frames to send after the socket opens (max 50).",
|
|
1162
|
-
},
|
|
1163
|
-
expectMessages: {
|
|
1164
|
-
type: "number",
|
|
1165
|
-
description:
|
|
1166
|
-
"How many inbound messages must be observed before success (default: messages.length, or 1 with no sent messages; max 50).",
|
|
1167
|
-
},
|
|
1168
|
-
timeoutMs: {
|
|
1169
|
-
type: "number",
|
|
1170
|
-
description: "Bounded verification timeout in milliseconds (default 3000, max 30000).",
|
|
1171
|
-
},
|
|
1172
|
-
entry: {
|
|
1173
|
-
type: "string",
|
|
1174
|
-
description: "Backend entry file (default: backend.ts | app.ts).",
|
|
1175
|
-
},
|
|
1176
|
-
},
|
|
1177
|
-
required: ["path"],
|
|
1178
|
-
additionalProperties: false,
|
|
1179
|
-
},
|
|
1180
|
-
handler: (args, context) => wsHandler(cwd, args, context),
|
|
1181
|
-
},
|
|
1182
|
-
{
|
|
1183
|
-
name: "nifra_test",
|
|
1184
|
-
description:
|
|
1185
|
-
"Run `bun test` for this project and return bounded structured results: { ok, command, durationMs, exitCode, timedOut, summary, stdout, stderr }. Pass `pattern` to narrow to a test file/path; pass `timeoutMs` (default 30000, max 300000). Use after editing code, alongside nifra_run/nifra_render for behavioral checks.",
|
|
1186
|
-
inputSchema: {
|
|
1187
|
-
type: "object",
|
|
1188
|
-
properties: {
|
|
1189
|
-
pattern: {
|
|
1190
|
-
type: "string",
|
|
1191
|
-
description: "Optional test file/path pattern passed as an argv item to `bun test`.",
|
|
1192
|
-
},
|
|
1193
|
-
timeoutMs: {
|
|
1194
|
-
type: "number",
|
|
1195
|
-
description: "Timeout in milliseconds (default 30000, max 300000).",
|
|
1196
|
-
},
|
|
1197
|
-
dir: {
|
|
1198
|
-
type: "string",
|
|
1199
|
-
description:
|
|
1200
|
-
'Run the tests in this subdirectory (relative to the project root), e.g. "app". Use it when the MCP server\'s root is a monorepo but you want to test just one app. Default: the project root.',
|
|
1201
|
-
},
|
|
1202
|
-
},
|
|
1203
|
-
additionalProperties: false,
|
|
1204
|
-
},
|
|
1205
|
-
handler: async (args, context) => {
|
|
1206
|
-
const { collectTestResult } = await import("./test-tool.ts")
|
|
1207
|
-
const opts = args as { dir?: string }
|
|
1208
|
-
const target = resolveProjectDir(cwd, opts.dir)
|
|
1209
|
-
if (target === null) return dirError(opts.dir)
|
|
1210
|
-
return JSON.stringify(
|
|
1211
|
-
await collectTestResult(target, args, { signal: context.signal }),
|
|
1212
|
-
null,
|
|
1213
|
-
2,
|
|
1214
|
-
)
|
|
1215
|
-
},
|
|
1216
|
-
},
|
|
1217
|
-
// nifra_docs + nifra_example are project-independent (corpus-backed); the shared factory keeps their
|
|
1218
|
-
// definitions identical to the CLI HTTP server and the site's edge worker.
|
|
1219
|
-
...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
|
|
1220
|
-
{
|
|
1221
|
-
name: "nifra_scaffold",
|
|
1222
|
-
description:
|
|
1223
|
-
'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong - this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns the file to create + the route-module contract (loader/action/meta/default) + a stub (ready-to-write for react/preact/solid; path+contract for vue/svelte/vanilla - use nifra_example for those bodies).',
|
|
1224
|
-
inputSchema: {
|
|
1225
|
-
type: "object",
|
|
1226
|
-
properties: {
|
|
1227
|
-
path: {
|
|
1228
|
-
type: "string",
|
|
1229
|
-
description: 'The URL path for the new page route, e.g. "/users/:id" or "/blog/*slug".',
|
|
1230
|
-
},
|
|
1231
|
-
write: {
|
|
1232
|
-
type: "boolean",
|
|
1233
|
-
description:
|
|
1234
|
-
"When true, create the file if a verified ready-to-write stub exists. Refuses overwrite.",
|
|
1235
|
-
},
|
|
1236
|
-
},
|
|
1237
|
-
required: ["path"],
|
|
1238
|
-
additionalProperties: false,
|
|
1239
|
-
},
|
|
1240
|
-
handler: async (args) => {
|
|
1241
|
-
const { path, write } = args as { path?: string; write?: boolean }
|
|
1242
|
-
if (typeof path !== "string" || path.length === 0) return "scaffold: `path` is required."
|
|
1243
|
-
const { frameworkFromClientModule, renderScaffold, writeScaffoldRoute } = await import(
|
|
1244
|
-
"./scaffold.ts"
|
|
1245
|
-
)
|
|
1246
|
-
const app = await loadAppCached()
|
|
1247
|
-
const framework = frameworkFromClientModule(app.framework.clientModule)
|
|
1248
|
-
if (write !== true) return renderScaffold(path, framework)
|
|
1249
|
-
const result = await writeScaffoldRoute(cwd, path, framework)
|
|
1250
|
-
const status = result.written
|
|
1251
|
-
? `Written: \`${result.file}\``
|
|
1252
|
-
: `Not written: ${result.reason ?? "no write performed"}`
|
|
1253
|
-
return `${status}\n\n${renderScaffold(path, framework)}`
|
|
1254
|
-
},
|
|
1255
|
-
},
|
|
1256
|
-
{
|
|
1257
|
-
name: "nifra_hydrate",
|
|
1258
|
-
description: "Run the hydration assurance gate and return stable NF-H diagnostics.",
|
|
1259
|
-
inputSchema: {
|
|
1260
|
-
type: "object",
|
|
1261
|
-
properties: {
|
|
1262
|
-
dir: { type: "string", description: "Project subdirectory to inspect." },
|
|
1263
|
-
interact: { type: "boolean", description: "Run declared hydration probes." },
|
|
1264
|
-
},
|
|
1265
|
-
additionalProperties: false,
|
|
1266
|
-
},
|
|
1267
|
-
handler: async (args) => {
|
|
1268
|
-
const opts = args as { dir?: string; interact?: boolean }
|
|
1269
|
-
const target = resolveProjectDir(cwd, opts.dir)
|
|
1270
|
-
if (target === null) return dirError(opts.dir)
|
|
1271
|
-
const { runHydrationAssurance } = await import("./assure-hydration.ts")
|
|
1272
|
-
return JSON.stringify(
|
|
1273
|
-
await runHydrationAssurance(target, { interact: opts.interact === true }),
|
|
1274
|
-
null,
|
|
1275
|
-
2,
|
|
1276
|
-
)
|
|
1277
|
-
},
|
|
1278
|
-
},
|
|
1279
|
-
]
|
|
1280
|
-
return [...catalogTools, ...legacyTools]
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
type ToolBackend = {
|
|
1284
|
-
readonly routes?: () => readonly unknown[]
|
|
1285
|
-
readonly fetch: (req: Request) => Promise<Response>
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
/** Extract tools registered via .tool() routes on the Nifra backend. Exported for the test that proves a
|
|
1289
|
-
* `server().tool()` route surfaces in `tools/list` and executes through `tools/call`. */
|
|
1290
|
-
export function extractBackendTools(backend: unknown): McpTool[] {
|
|
1291
|
-
const b = backend as ToolBackend | null
|
|
1292
|
-
if (!b || typeof b.routes !== "function") return []
|
|
1293
|
-
const routes = reflectRoutes(b)
|
|
1294
|
-
|
|
1295
|
-
return routes
|
|
1296
|
-
.filter(
|
|
1297
|
-
(
|
|
1298
|
-
r,
|
|
1299
|
-
): r is ReflectedRoute & {
|
|
1300
|
-
schema: NonNullable<ReflectedRoute["schema"]>
|
|
1301
|
-
tool: NonNullable<ReflectedRoute["tool"]>
|
|
1302
|
-
} => r.schema !== undefined && r.tool !== undefined,
|
|
1303
|
-
)
|
|
1304
|
-
.map((r) => {
|
|
1305
|
-
const toolInfo = r.tool
|
|
1306
|
-
const s = r.schema
|
|
1307
|
-
return {
|
|
1308
|
-
name: toolInfo.name,
|
|
1309
|
-
description: toolInfo.description,
|
|
1310
|
-
inputSchema: (s.body?.jsonSchema ?? {
|
|
1311
|
-
type: "object",
|
|
1312
|
-
properties: {},
|
|
1313
|
-
}) as McpTool["inputSchema"],
|
|
1314
|
-
...(toolInfo.annotations !== undefined ? { annotations: toolInfo.annotations } : {}),
|
|
1315
|
-
handler: async (args): Promise<string | McpToolResult> => {
|
|
1316
|
-
const res = await b.fetch(
|
|
1317
|
-
new Request(`http://localhost/_nifra/tool/${toolInfo.name}`, {
|
|
1318
|
-
method: "POST",
|
|
1319
|
-
headers: { "content-type": "application/json" },
|
|
1320
|
-
body: JSON.stringify(args),
|
|
1321
|
-
}),
|
|
1322
|
-
)
|
|
1323
|
-
if (!res.ok) {
|
|
1324
|
-
const text = await res.text()
|
|
1325
|
-
throw new Error(`Tool execution failed (${res.status}): ${text}`)
|
|
1326
|
-
}
|
|
1327
|
-
const body: unknown = await res.json()
|
|
1328
|
-
if (typeof body === "string") return body
|
|
1329
|
-
if (body && typeof body === "object") {
|
|
1330
|
-
if ("content" in body || "structuredContent" in body) {
|
|
1331
|
-
return body as McpToolResult
|
|
1332
|
-
}
|
|
1333
|
-
return {
|
|
1334
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
1335
|
-
structuredContent: body as Record<string, unknown>,
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
return String(body)
|
|
1339
|
-
},
|
|
1340
|
-
}
|
|
1341
|
-
})
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
/** A resource declared on the backend via `server().resource()`, seen through `mcpResources()`. */
|
|
1345
|
-
type ResourceDescriptor = {
|
|
1346
|
-
readonly uri: string
|
|
1347
|
-
readonly name: string
|
|
1348
|
-
readonly description?: string
|
|
1349
|
-
readonly mimeType?: string
|
|
1350
|
-
readonly read: () => unknown | Promise<unknown>
|
|
1351
|
-
}
|
|
1352
|
-
type ResourceBackend = { readonly mcpResources?: () => readonly ResourceDescriptor[] }
|
|
1353
|
-
|
|
1354
|
-
/** Extract MCP resources registered via `.resource()` on the Nifra backend. Exported for the surfacing test. */
|
|
1355
|
-
export function extractBackendResources(backend: unknown): McpResource[] {
|
|
1356
|
-
const b = backend as ResourceBackend | null
|
|
1357
|
-
if (!b || typeof b.mcpResources !== "function") return []
|
|
1358
|
-
let list: readonly ResourceDescriptor[]
|
|
1359
|
-
try {
|
|
1360
|
-
list = b.mcpResources()
|
|
1361
|
-
} catch {
|
|
1362
|
-
return []
|
|
1363
|
-
}
|
|
1364
|
-
return list.map((r) => ({
|
|
1365
|
-
uri: r.uri,
|
|
1366
|
-
name: r.name,
|
|
1367
|
-
...(r.description !== undefined ? { description: r.description } : {}),
|
|
1368
|
-
...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
|
|
1369
|
-
read: async () => {
|
|
1370
|
-
const out = await r.read()
|
|
1371
|
-
if (typeof out === "string") return { text: out }
|
|
1372
|
-
const o = out as { text: string; mimeType?: string }
|
|
1373
|
-
return o.mimeType !== undefined ? { text: o.text, mimeType: o.mimeType } : { text: o.text }
|
|
1374
|
-
},
|
|
1375
|
-
}))
|
|
1376
|
-
}
|
|
1377
82
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
83
|
+
export type { BoundedOutput } from "./mcp-io.ts"
|
|
84
|
+
export {
|
|
85
|
+
CHILD_OUTPUT_MAX_BYTES,
|
|
86
|
+
CHILD_TIMEOUT_MS,
|
|
87
|
+
LOCAL_TOOL_FETCH_TIMEOUT_MS,
|
|
88
|
+
LOCAL_TOOL_MAX_RESPONSE_BYTES,
|
|
89
|
+
notNifraResponse,
|
|
90
|
+
readBoundedResponse,
|
|
91
|
+
readBoundedStream,
|
|
92
|
+
timeoutMessage,
|
|
93
|
+
validateLocalPort,
|
|
94
|
+
} from "./mcp-io.ts"
|
|
95
|
+
export { clientSupportsRoots, guardTools } from "./mcp-root.ts"
|
|
96
|
+
|
|
97
|
+
import { createCachedAppLoader, projectTools } from "./mcp-exec.ts"
|
|
98
|
+
|
|
99
|
+
export type { CachedAppLoaderOptions } from "./mcp-exec.ts"
|
|
100
|
+
export {
|
|
101
|
+
catalogProjectTools,
|
|
102
|
+
createCachedAppLoader,
|
|
103
|
+
createWarmHandler,
|
|
104
|
+
projectTools,
|
|
105
|
+
renderHandler,
|
|
106
|
+
runHandler,
|
|
107
|
+
spawnChild,
|
|
108
|
+
toMcpTool,
|
|
109
|
+
WarmWorker,
|
|
110
|
+
wsHandler,
|
|
111
|
+
} from "./mcp-exec.ts"
|
|
112
|
+
|
|
113
|
+
import { namespaceForApp, projectFeatures } from "./mcp-context.ts"
|
|
114
|
+
|
|
115
|
+
export type { CommandMcpToolOptions } from "./mcp-context.ts"
|
|
116
|
+
export {
|
|
117
|
+
dirError,
|
|
118
|
+
namespaceForApp,
|
|
119
|
+
projectFeatures,
|
|
120
|
+
projectPrompts,
|
|
121
|
+
projectResources,
|
|
122
|
+
resolveProjectDir,
|
|
123
|
+
} from "./mcp-context.ts"
|
|
1386
124
|
|
|
1387
|
-
/** Extract MCP prompts registered via `.prompt()` on the Nifra backend. Exported for the surfacing test. */
|
|
1388
|
-
export function extractBackendPrompts(backend: unknown): McpPrompt[] {
|
|
1389
|
-
const b = backend as PromptBackend | null
|
|
1390
|
-
if (!b || typeof b.mcpPrompts !== "function") return []
|
|
1391
|
-
let list: readonly PromptDescriptor[]
|
|
1392
|
-
try {
|
|
1393
|
-
list = b.mcpPrompts()
|
|
1394
|
-
} catch {
|
|
1395
|
-
return []
|
|
1396
|
-
}
|
|
1397
|
-
return list.map((p) => ({
|
|
1398
|
-
name: p.name,
|
|
1399
|
-
description: p.description,
|
|
1400
|
-
...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
|
|
1401
|
-
handler: async (args: Record<string, unknown>) =>
|
|
1402
|
-
(await p.handler(args as Record<string, string>)) as readonly McpPromptMessage[],
|
|
1403
|
-
}))
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
/** Prefix every tool name and resource URI for a named app in a monorepo. */
|
|
1407
|
-
function namespaceForApp(
|
|
1408
|
-
name: string,
|
|
1409
|
-
tools: McpTool[],
|
|
1410
|
-
features: McpServerFeatures,
|
|
1411
|
-
): { tools: McpTool[]; features: McpServerFeatures } {
|
|
1412
|
-
const prefix = `nifra_${name}_`
|
|
1413
|
-
const namespacedTools = tools.map((t) => ({
|
|
1414
|
-
...t,
|
|
1415
|
-
name: t.name.startsWith("nifra_") ? t.name.replace(/^nifra_/, prefix) : `${name}_${t.name}`,
|
|
1416
|
-
}))
|
|
1417
|
-
const namespacedResources = (features.resources ?? []).map((r) => ({
|
|
1418
|
-
...r,
|
|
1419
|
-
uri: r.uri.replace(/^nifra:\/\//, `nifra://${name}/`),
|
|
1420
|
-
}))
|
|
1421
|
-
const namespacedPrompts = (features.prompts ?? []).map((p) => ({
|
|
1422
|
-
...p,
|
|
1423
|
-
name: p.name.replace(/^nifra_/, prefix),
|
|
1424
|
-
}))
|
|
1425
|
-
return {
|
|
1426
|
-
tools: namespacedTools,
|
|
1427
|
-
features: { resources: namespacedResources, prompts: namespacedPrompts },
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* Merge the backend-derived resources/prompts into `base` - and keep a project that cannot be loaded
|
|
1433
|
-
* from deciding whether the server starts at all.
|
|
1434
|
-
*
|
|
1435
|
-
* Resources and prompts are OPTIONAL capabilities extracted from the app's backend, so this load is a
|
|
1436
|
-
* best-effort enrichment. It was awaited unguarded, which made it a hard boot requirement: `loadApp`
|
|
1437
|
-
* needs a web config (`adapter` + `clientModule`) and a `routes/` directory, and a backend-only
|
|
1438
|
-
* project - the shape `create-nifra`'s DEFAULT template produces - has neither. On those projects the
|
|
1439
|
-
* throw escaped before the JSON-RPC loop began, so the server wrote nothing and exited. `nifra
|
|
1440
|
-
* init-agents` would happily register an `.mcp.json` pointing at a server that could never start, and
|
|
1441
|
-
* every tool went with it, including the twelve that never touch the app (docs, examples, types,
|
|
1442
|
-
* check, doctor, levels, test).
|
|
1443
|
-
*
|
|
1444
|
-
* Degrading costs only the backend resources/prompts. Tools that genuinely need the app still fail,
|
|
1445
|
-
* but per call, with their own message - which is a diagnosis instead of a silence.
|
|
1446
|
-
*/
|
|
1447
125
|
async function backendFeatures(
|
|
1448
126
|
loader: () => Promise<LoadedApp>,
|
|
1449
127
|
base: McpServerFeatures,
|
|
@@ -1476,13 +154,6 @@ async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTo
|
|
|
1476
154
|
}
|
|
1477
155
|
}
|
|
1478
156
|
|
|
1479
|
-
/** Tool names that never touch the project - they serve the bundled docs/examples/types corpus, so
|
|
1480
|
-
* they stay available (and unstamped) whatever the root state is. */
|
|
1481
|
-
const PROJECT_FREE_TOOLS = new Set(["nifra_docs", "nifra_example", "nifra_types", "nifra_learn"])
|
|
1482
|
-
|
|
1483
|
-
/** The id of this server's own `roots/list` request to the client. A fixed, namespaced string: the
|
|
1484
|
-
* server only ever has one roots request in flight, and no response to a CLIENT-initiated request can
|
|
1485
|
-
* carry it (clients pick their own ids; this one is recognisably ours). */
|
|
1486
157
|
const ROOTS_REQUEST_ID = "nifra:roots/list"
|
|
1487
158
|
|
|
1488
159
|
/** Everything derived from the project root - rebuilt wholesale when the root changes (adoption of a
|
|
@@ -1523,53 +194,11 @@ async function createProjectContext(root: string): Promise<ProjectContext> {
|
|
|
1523
194
|
}
|
|
1524
195
|
}
|
|
1525
196
|
|
|
1526
|
-
/**
|
|
1527
|
-
* Wrap every project-scoped tool with the root guard: a bad root ({@link rootVerdict} `blocked`) makes
|
|
1528
|
-
* the tool refuse with the remediation message instead of answering about whatever directory the
|
|
1529
|
-
* server happened to start in; a good root stamps the result with `[nifra] project root: …` so the
|
|
1530
|
-
* agent (and the human reading its transcript) can see WHICH project answered. Docs tools pass
|
|
1531
|
-
* through untouched - they serve the bundled corpus, not the project.
|
|
1532
|
-
*/
|
|
1533
|
-
export function guardTools(tools: McpTool[], verdict: McpRootVerdict): McpTool[] {
|
|
1534
|
-
return tools.map((tool) => {
|
|
1535
|
-
if (PROJECT_FREE_TOOLS.has(tool.name)) return tool
|
|
1536
|
-
return {
|
|
1537
|
-
...tool,
|
|
1538
|
-
handler: async (
|
|
1539
|
-
args: Record<string, unknown>,
|
|
1540
|
-
context: McpToolContext,
|
|
1541
|
-
): Promise<McpToolResult> => {
|
|
1542
|
-
if (verdict.blocked !== undefined) {
|
|
1543
|
-
return { content: [{ type: "text", text: verdict.blocked }], isError: true }
|
|
1544
|
-
}
|
|
1545
|
-
const result = await tool.handler(args, context)
|
|
1546
|
-
const note: McpContentBlock = { type: "text", text: verdict.note }
|
|
1547
|
-
if (typeof result === "string") {
|
|
1548
|
-
return { content: [{ type: "text", text: result }, note] }
|
|
1549
|
-
}
|
|
1550
|
-
return { ...result, content: [...(result.content ?? []), note] }
|
|
1551
|
-
},
|
|
1552
|
-
}
|
|
1553
|
-
})
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
/** Whether the client's `initialize` params declare the `roots` capability - the precondition for
|
|
1557
|
-
* this server sending it a `roots/list` request. */
|
|
1558
|
-
export function clientSupportsRoots(params: Record<string, unknown> | undefined): boolean {
|
|
1559
|
-
const capabilities = params?.capabilities
|
|
1560
|
-
if (typeof capabilities !== "object" || capabilities === null) return false
|
|
1561
|
-
const roots = (capabilities as { roots?: unknown }).roots
|
|
1562
|
-
return typeof roots === "object" && roots !== null
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
197
|
/**
|
|
1566
198
|
* Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout.
|
|
1567
199
|
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
* MCP `roots` (requested after the handshake, re-requested on `roots/list_changed`) can correct an
|
|
1571
|
-
* unambiguous wrong guess, and anything still unresolved fails closed per tool call. The effective
|
|
1572
|
-
* root is announced in the `initialize` instructions and stamped on every project tool result.
|
|
200
|
+
* Root resolution, trust, and project-tool execution are delegated to their own seams; this function
|
|
201
|
+
* owns only the stdio lifecycle and JSON-RPC dispatch loop.
|
|
1573
202
|
*/
|
|
1574
203
|
export async function runMcpServer(
|
|
1575
204
|
cwd: string,
|
|
@@ -1582,11 +211,7 @@ export async function runMcpServer(
|
|
|
1582
211
|
}
|
|
1583
212
|
let rootState = await resolveRootState(requested, explicitDir !== undefined)
|
|
1584
213
|
let ctx = await createProjectContext(rootState.root)
|
|
1585
|
-
// The CLI answering may not be the nifra the project builds with (a globally installed server, a
|
|
1586
|
-
// stale agent config). Recomputed whenever the root moves, since a different project can install a
|
|
1587
|
-
// different version.
|
|
1588
214
|
let drift: ToolingDrift | undefined = await detectToolingDrift(rootState.root, version)
|
|
1589
|
-
|
|
1590
215
|
const serverInfo = { name: "nifra", version }
|
|
1591
216
|
const state = createMcpProtocolState()
|
|
1592
217
|
const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
|