@nifrajs/cli 0.1.0-alpha.1

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/check.d.ts +100 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +690 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +208 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/docs-search.d.ts +30 -0
  12. package/dist/docs-search.d.ts.map +1 -0
  13. package/dist/docs-search.js +94 -0
  14. package/dist/docs-search.js.map +1 -0
  15. package/dist/doctor.d.ts +52 -0
  16. package/dist/doctor.d.ts.map +1 -0
  17. package/dist/doctor.js +291 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/examples.d.ts +30 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +70 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/introspect.d.ts +64 -0
  24. package/dist/introspect.d.ts.map +1 -0
  25. package/dist/introspect.js +243 -0
  26. package/dist/introspect.js.map +1 -0
  27. package/dist/load.d.ts +51 -0
  28. package/dist/load.d.ts.map +1 -0
  29. package/dist/load.js +70 -0
  30. package/dist/load.js.map +1 -0
  31. package/dist/mcp-docs-tools.d.ts +13 -0
  32. package/dist/mcp-docs-tools.d.ts.map +1 -0
  33. package/dist/mcp-docs-tools.js +63 -0
  34. package/dist/mcp-docs-tools.js.map +1 -0
  35. package/dist/mcp-http.d.ts +43 -0
  36. package/dist/mcp-http.d.ts.map +1 -0
  37. package/dist/mcp-http.js +136 -0
  38. package/dist/mcp-http.js.map +1 -0
  39. package/dist/mcp-protocol.d.ts +97 -0
  40. package/dist/mcp-protocol.d.ts.map +1 -0
  41. package/dist/mcp-protocol.js +248 -0
  42. package/dist/mcp-protocol.js.map +1 -0
  43. package/dist/mcp-render.d.ts +17 -0
  44. package/dist/mcp-render.d.ts.map +1 -0
  45. package/dist/mcp-render.js +73 -0
  46. package/dist/mcp-render.js.map +1 -0
  47. package/dist/mcp-run.d.ts +24 -0
  48. package/dist/mcp-run.d.ts.map +1 -0
  49. package/dist/mcp-run.js +142 -0
  50. package/dist/mcp-run.js.map +1 -0
  51. package/dist/mcp-ws.d.ts +26 -0
  52. package/dist/mcp-ws.d.ts.map +1 -0
  53. package/dist/mcp-ws.js +258 -0
  54. package/dist/mcp-ws.js.map +1 -0
  55. package/dist/mcp.d.ts +42 -0
  56. package/dist/mcp.d.ts.map +1 -0
  57. package/dist/mcp.js +712 -0
  58. package/dist/mcp.js.map +1 -0
  59. package/dist/openapi-tool.d.ts +6 -0
  60. package/dist/openapi-tool.d.ts.map +1 -0
  61. package/dist/openapi-tool.js +48 -0
  62. package/dist/openapi-tool.js.map +1 -0
  63. package/dist/scaffold.d.ts +35 -0
  64. package/dist/scaffold.d.ts.map +1 -0
  65. package/dist/scaffold.js +140 -0
  66. package/dist/scaffold.js.map +1 -0
  67. package/dist/search-terms.d.ts +17 -0
  68. package/dist/search-terms.d.ts.map +1 -0
  69. package/dist/search-terms.js +82 -0
  70. package/dist/search-terms.js.map +1 -0
  71. package/dist/test-tool.d.ts +29 -0
  72. package/dist/test-tool.d.ts.map +1 -0
  73. package/dist/test-tool.js +150 -0
  74. package/dist/test-tool.js.map +1 -0
  75. package/docs/examples.json +314 -0
  76. package/docs/llms-full.txt +4325 -0
  77. package/package.json +58 -0
  78. package/src/check.ts +790 -0
  79. package/src/cli.ts +237 -0
  80. package/src/docs-search.ts +111 -0
  81. package/src/doctor.ts +349 -0
  82. package/src/examples.ts +89 -0
  83. package/src/introspect.ts +284 -0
  84. package/src/load.ts +129 -0
  85. package/src/mcp-docs-tools.ts +72 -0
  86. package/src/mcp-http.ts +155 -0
  87. package/src/mcp-protocol.ts +326 -0
  88. package/src/mcp-render.ts +78 -0
  89. package/src/mcp-run.ts +153 -0
  90. package/src/mcp-ws.ts +328 -0
  91. package/src/mcp.ts +863 -0
  92. package/src/openapi-tool.ts +51 -0
  93. package/src/scaffold.ts +164 -0
  94. package/src/search-terms.ts +89 -0
  95. package/src/test-tool.ts +181 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,863 @@
1
+ /**
2
+ * `nifra mcp` — a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
3
+ * act on a nifra project, not just read about it:
4
+ *
5
+ * - `nifra_context` — this project's API routes + page routes + conventions (see {@link describeProject}).
6
+ * - `nifra_run` — run HTTP requests through the project's backend and return structured results
7
+ * (status, body, errors). The write → run → see-the-failure → fix loop, powered by `@nifrajs/runner`.
8
+ * Each call runs the backend in a FRESH subprocess, so it reflects the agent's latest edits.
9
+ * - `nifra_render` — SSR a page route through the project's web app; returns the rendered HTML (the
10
+ * page half of `nifra_run`). Fresh subprocess per call; no build needed.
11
+ * - `nifra_ws` — verify a WebSocket route with a real Bun WebSocket round-trip.
12
+ * - `nifra_docs` — keyword-search the framework docs; returns only the matching sections.
13
+ * - `nifra_example` — a verified, copy-pasteable snippet for a task (typechecked against the live API,
14
+ * so it can't hallucinate a drifted nifra API).
15
+ * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
+ * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
+ * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
18
+ *
19
+ * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
20
+ * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
21
+ * standard MCP progress notifications and request cancellation — no SDK dependency, the same
22
+ * minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
23
+ * module is the I/O shell (stdin loop, tool wiring, the run subprocess).
24
+ */
25
+
26
+ import { readFile, stat } from "node:fs/promises"
27
+ import { basename, resolve, sep } from "node:path"
28
+ import { fileURLToPath } from "node:url"
29
+ import { Glob } from "bun"
30
+ import { loadDocsCorpus } from "./docs-search.ts"
31
+ import { loadExamplesCorpus } from "./examples.ts"
32
+ import { describeProject } from "./introspect.ts"
33
+ import type { LoadAppOptions, LoadedApp } from "./load.ts"
34
+ import { docsTools } from "./mcp-docs-tools.ts"
35
+ import {
36
+ createMcpProtocolState,
37
+ handleRpc,
38
+ type JsonRpcNotification,
39
+ type JsonRpcRequest,
40
+ type JsonRpcResponse,
41
+ type McpPrompt,
42
+ type McpResource,
43
+ type McpServerFeatures,
44
+ type McpTool,
45
+ type McpToolContext,
46
+ rpcError,
47
+ } from "./mcp-protocol.ts"
48
+
49
+ /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
50
+ * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
51
+ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
52
+ return fileURLToPath(new URL(import.meta.url)).replace(/mcp\.(ts|js)$/, `${name}.$1`)
53
+ }
54
+
55
+ /** Spawn `bun <child> <cwd>`, pipe `input` to its stdin, return its stdout (or a stderr-backed error). */
56
+ async function spawnChild(
57
+ child: "mcp-run" | "mcp-render" | "mcp-ws",
58
+ cwd: string,
59
+ input: unknown,
60
+ label: string,
61
+ signal?: AbortSignal,
62
+ ): Promise<string> {
63
+ if (signal?.aborted) return `${label} cancelled before it started.`
64
+ const proc = Bun.spawn(["bun", childPath(child), cwd], {
65
+ stdin: "pipe",
66
+ stdout: "pipe",
67
+ stderr: "pipe",
68
+ })
69
+ const abort = (): void => proc.kill()
70
+ signal?.addEventListener("abort", abort, { once: true })
71
+ try {
72
+ proc.stdin.write(JSON.stringify(input))
73
+ await proc.stdin.end()
74
+ const [out, err] = await Promise.all([
75
+ new Response(proc.stdout).text(),
76
+ new Response(proc.stderr).text(),
77
+ ])
78
+ await proc.exited
79
+ if (signal?.aborted) {
80
+ const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
81
+ return `${label} cancelled${reason}.`
82
+ }
83
+ return out.trim() || `${label} failed:\n${err.trim() || "(no output)"}`
84
+ } finally {
85
+ signal?.removeEventListener("abort", abort)
86
+ }
87
+ }
88
+
89
+ const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
90
+ const WARM_RUN_IGNORED =
91
+ /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
92
+ const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
93
+
94
+ async function warmRunFingerprint(cwd: string): Promise<string> {
95
+ const parts: string[] = []
96
+ for await (const rel of WARM_RUN_GLOB.scan({ cwd, dot: false })) {
97
+ if (WARM_RUN_IGNORED.test(rel)) continue
98
+ try {
99
+ const s = await stat(resolve(cwd, rel))
100
+ if (s.isFile()) parts.push(`${rel}:${s.mtimeMs}:${s.size}`)
101
+ } catch {
102
+ // A file can disappear while an agent is editing. The next call will rescan the settled tree.
103
+ }
104
+ }
105
+ for (const rel of WARM_RUN_EXTRA_FILES) {
106
+ parts.push(`${rel}:${await fileFingerprint(resolve(cwd, rel))}`)
107
+ }
108
+ return cacheToken(parts.sort().join("|"))
109
+ }
110
+
111
+ function boundedAppend(current: string, next: string, max = 12_000): string {
112
+ const combined = `${current}${next}`
113
+ return combined.length <= max ? combined : combined.slice(combined.length - max)
114
+ }
115
+
116
+ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
117
+ readonly stdin: { write(input: string | Uint8Array): unknown }
118
+ readonly stdout: ReadableStream<Uint8Array>
119
+ readonly stderr: ReadableStream<Uint8Array>
120
+ }
121
+
122
+ class WarmRunWorker {
123
+ private readonly proc: PipeSubprocess
124
+ private readonly pending = new Map<
125
+ number,
126
+ {
127
+ readonly resolve: (text: string) => void
128
+ readonly reject: (err: Error) => void
129
+ readonly cleanup: () => void
130
+ }
131
+ >()
132
+ private stdoutBuffer = ""
133
+ private stderrBuffer = ""
134
+ private nextId = 0
135
+ private closed = false
136
+
137
+ constructor(
138
+ cwd: string,
139
+ readonly fingerprint: string,
140
+ ) {
141
+ this.proc = Bun.spawn(["bun", childPath("mcp-run"), cwd, "--worker"], {
142
+ stdin: "pipe",
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ }) as PipeSubprocess
146
+ void this.readStdout()
147
+ void this.readStderr()
148
+ void this.proc.exited.then((code) => {
149
+ this.closed = true
150
+ const err = new Error(
151
+ `warm run worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
152
+ )
153
+ for (const pending of this.pending.values()) {
154
+ pending.cleanup()
155
+ pending.reject(err)
156
+ }
157
+ this.pending.clear()
158
+ })
159
+ }
160
+
161
+ stop(): void {
162
+ if (!this.closed) this.proc.kill()
163
+ }
164
+
165
+ async request(input: unknown, signal?: AbortSignal): Promise<string> {
166
+ if (this.closed) throw new Error("warm run worker is closed")
167
+ if (signal?.aborted) return "run cancelled before it started."
168
+ const id = ++this.nextId
169
+ return new Promise((resolve, reject) => {
170
+ const abort = (): void => {
171
+ this.pending.delete(id)
172
+ this.stop()
173
+ const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
174
+ resolve(`run cancelled${reason}.`)
175
+ }
176
+ const cleanup = (): void => signal?.removeEventListener("abort", abort)
177
+ signal?.addEventListener("abort", abort, { once: true })
178
+ this.pending.set(id, { resolve, reject, cleanup })
179
+ try {
180
+ this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`)
181
+ } catch (err) {
182
+ this.pending.delete(id)
183
+ cleanup()
184
+ reject(err instanceof Error ? err : new Error(String(err)))
185
+ }
186
+ })
187
+ }
188
+
189
+ private async readStdout(): Promise<void> {
190
+ const decoder = new TextDecoder()
191
+ const reader = this.proc.stdout.getReader()
192
+ for (;;) {
193
+ const { done, value } = await reader.read()
194
+ if (done) break
195
+ this.stdoutBuffer += decoder.decode(value, { stream: true })
196
+ let nl = this.stdoutBuffer.indexOf("\n")
197
+ while (nl !== -1) {
198
+ const line = this.stdoutBuffer.slice(0, nl).trim()
199
+ this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
200
+ nl = this.stdoutBuffer.indexOf("\n")
201
+ if (line !== "") this.handleLine(line)
202
+ }
203
+ }
204
+ }
205
+
206
+ private async readStderr(): Promise<void> {
207
+ const decoder = new TextDecoder()
208
+ const reader = this.proc.stderr.getReader()
209
+ for (;;) {
210
+ const { done, value } = await reader.read()
211
+ if (done) break
212
+ this.stderrBuffer = boundedAppend(this.stderrBuffer, decoder.decode(value, { stream: true }))
213
+ }
214
+ }
215
+
216
+ private handleLine(line: string): void {
217
+ let message: { id?: unknown; output?: unknown }
218
+ try {
219
+ message = JSON.parse(line) as { id?: unknown; output?: unknown }
220
+ } catch {
221
+ this.stderrBuffer = boundedAppend(this.stderrBuffer, `invalid warm worker line: ${line}\n`)
222
+ return
223
+ }
224
+ if (typeof message.id !== "number") return
225
+ const pending = this.pending.get(message.id)
226
+ if (pending === undefined) return
227
+ this.pending.delete(message.id)
228
+ pending.cleanup()
229
+ pending.resolve(JSON.stringify(message.output, null, 2))
230
+ }
231
+ }
232
+
233
+ function createWarmRunHandler(
234
+ cwd: string,
235
+ ): (input: unknown, signal?: AbortSignal) => Promise<string> {
236
+ let worker: WarmRunWorker | undefined
237
+ return async (input, signal) => {
238
+ const fingerprint = await warmRunFingerprint(cwd)
239
+ if (worker === undefined || worker.fingerprint !== fingerprint) {
240
+ worker?.stop()
241
+ worker = new WarmRunWorker(cwd, fingerprint)
242
+ }
243
+ try {
244
+ return await worker.request(input, signal)
245
+ } catch {
246
+ worker.stop()
247
+ worker = undefined
248
+ if (signal?.aborted) {
249
+ const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
250
+ return `run cancelled${reason}.`
251
+ }
252
+ return spawnChild("mcp-run", cwd, input, "run", signal)
253
+ }
254
+ }
255
+ }
256
+
257
+ /** The `nifra_run` handler: run requests through the project's CURRENT backend, return structured results. */
258
+ async function runHandler(
259
+ cwd: string,
260
+ args: Record<string, unknown>,
261
+ context: McpToolContext,
262
+ warmRun: (input: unknown, signal?: AbortSignal) => Promise<string>,
263
+ ): Promise<string> {
264
+ const requests = Array.isArray(args.requests) ? args.requests : []
265
+ if (requests.length === 0) {
266
+ return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
267
+ }
268
+ const input = { requests, entry: args.entry }
269
+ if ((args as { warm?: boolean }).warm === true) {
270
+ context.reportProgress(0.2, 1)
271
+ return warmRun(input, context.signal)
272
+ }
273
+ return spawnChild("mcp-run", cwd, input, "run", context.signal)
274
+ }
275
+
276
+ /** The `nifra_render` handler: SSR page routes through the project's CURRENT web app, return the HTML. */
277
+ async function renderHandler(
278
+ cwd: string,
279
+ args: Record<string, unknown>,
280
+ context: McpToolContext,
281
+ ): Promise<string> {
282
+ const requests = Array.isArray(args.requests) ? args.requests : []
283
+ if (requests.length === 0) {
284
+ return 'No requests provided. Pass { "requests": [{ "path": "/..." }] }.'
285
+ }
286
+ return spawnChild("mcp-render", cwd, { requests }, "render", context.signal)
287
+ }
288
+
289
+ /** The `nifra_ws` handler: verify WebSocket routes through a fresh app subprocess. */
290
+ async function wsHandler(
291
+ cwd: string,
292
+ args: Record<string, unknown>,
293
+ context: McpToolContext,
294
+ ): Promise<string> {
295
+ return spawnChild("mcp-ws", cwd, args, "websocket", context.signal)
296
+ }
297
+
298
+ function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
299
+ const format = args.format
300
+ return format === "yaml" ? "yaml" : "json"
301
+ }
302
+
303
+ type LoadAppForCache = (
304
+ cwd: string,
305
+ outDirName?: string,
306
+ options?: LoadAppOptions,
307
+ ) => Promise<LoadedApp>
308
+
309
+ export interface CachedAppLoaderOptions {
310
+ readonly loadApp?: LoadAppForCache
311
+ readonly fingerprint?: (cwd: string) => Promise<string>
312
+ }
313
+
314
+ const APP_FINGERPRINT_FILES = ["nifra.config.ts", "framework.ts", "backend.ts"] as const
315
+
316
+ function cacheToken(input: string): string {
317
+ let hash = 2166136261
318
+ for (let i = 0; i < input.length; i++) {
319
+ hash ^= input.charCodeAt(i)
320
+ hash = Math.imul(hash, 16777619)
321
+ }
322
+ return `mcp=${(hash >>> 0).toString(36)}`
323
+ }
324
+
325
+ async function fileFingerprint(path: string): Promise<string> {
326
+ try {
327
+ const s = await stat(path)
328
+ return `${s.mtimeMs}:${s.size}`
329
+ } catch (err) {
330
+ if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
331
+ return "missing"
332
+ }
333
+ throw err
334
+ }
335
+ }
336
+
337
+ async function appFingerprint(cwd: string): Promise<string> {
338
+ return (
339
+ await Promise.all(
340
+ APP_FINGERPRINT_FILES.map(
341
+ async (file) => `${file}:${await fileFingerprint(resolve(cwd, file))}`,
342
+ ),
343
+ )
344
+ ).join("|")
345
+ }
346
+
347
+ /** Cache LoadedApp inside one MCP server process, invalidating when config/backend mtimes change. */
348
+ export function createCachedAppLoader(
349
+ cwd: string,
350
+ options: CachedAppLoaderOptions = {},
351
+ ): (outDirName?: string) => Promise<LoadedApp> {
352
+ const loadAppCached =
353
+ options.loadApp ??
354
+ (async (root: string, outDirName?: string, loadOptions?: LoadAppOptions) => {
355
+ const mod = await import("./load.ts")
356
+ return mod.loadApp(root, outDirName, loadOptions)
357
+ })
358
+ const fingerprint = options.fingerprint ?? appFingerprint
359
+ let cached:
360
+ | {
361
+ readonly outDirName: string
362
+ readonly fingerprint: string
363
+ readonly app: LoadedApp
364
+ }
365
+ | undefined
366
+
367
+ return async (outDirName = "dist") => {
368
+ const nextFingerprint = await fingerprint(cwd)
369
+ if (
370
+ cached !== undefined &&
371
+ cached.outDirName === outDirName &&
372
+ cached.fingerprint === nextFingerprint
373
+ ) {
374
+ return cached.app
375
+ }
376
+ const app = await loadAppCached(cwd, outDirName, {
377
+ importQuery: cacheToken(nextFingerprint),
378
+ })
379
+ cached = { outDirName, fingerprint: nextFingerprint, app }
380
+ return app
381
+ }
382
+ }
383
+
384
+ async function openApiHandler(
385
+ args: Record<string, unknown>,
386
+ loadAppCached: (outDirName?: string) => Promise<LoadedApp>,
387
+ ): Promise<string> {
388
+ const { renderOpenApi } = await import("./openapi-tool.ts")
389
+ return renderOpenApi(await loadAppCached(), openApiFormat(args))
390
+ }
391
+
392
+ async function readProjectFile(
393
+ cwd: string,
394
+ relativeFile: string,
395
+ maxChars: number,
396
+ ): Promise<string> {
397
+ const target = resolve(cwd, relativeFile)
398
+ const root = resolve(cwd)
399
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
400
+ throw new Error(`refusing to read outside project root: ${relativeFile}`)
401
+ }
402
+ try {
403
+ const text = await readFile(target, "utf8")
404
+ return text.length <= maxChars
405
+ ? text
406
+ : `${text.slice(0, maxChars)}\n…(trimmed; read ${relativeFile} directly for the rest)`
407
+ } catch (err) {
408
+ if (err && typeof err === "object" && (err as { code?: string }).code === "ENOENT") {
409
+ return `No ${relativeFile} found in ${basename(cwd)}.`
410
+ }
411
+ throw err
412
+ }
413
+ }
414
+
415
+ function promptText(
416
+ text: string,
417
+ ): readonly [
418
+ { readonly role: "user"; readonly content: { readonly type: "text"; readonly text: string } },
419
+ ] {
420
+ return [{ role: "user", content: { type: "text", text } }]
421
+ }
422
+
423
+ export function projectResources(
424
+ cwd: string,
425
+ loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
426
+ ): McpResource[] {
427
+ return [
428
+ {
429
+ uri: "nifra://routes",
430
+ name: "API routes",
431
+ description:
432
+ "Structured API routes with typed-client calls and compact request/response shapes.",
433
+ mimeType: "application/json",
434
+ read: async () => {
435
+ const { routesToJson } = await import("./introspect.ts")
436
+ return { text: JSON.stringify(routesToJson(await loadAppCached()), null, 2) }
437
+ },
438
+ },
439
+ {
440
+ uri: "nifra://openapi",
441
+ name: "OpenAPI 3.1",
442
+ description: "OpenAPI document generated from backend.ts using @nifrajs/schema.",
443
+ mimeType: "application/json",
444
+ read: async () => ({ text: await openApiHandler({ format: "json" }, loadAppCached) }),
445
+ },
446
+ {
447
+ uri: "nifra://package-json",
448
+ name: "package.json",
449
+ description: "Project package metadata and scripts.",
450
+ mimeType: "application/json",
451
+ read: async () => ({ text: await readProjectFile(cwd, "package.json", 40_000) }),
452
+ },
453
+ {
454
+ uri: "nifra://agents-md",
455
+ name: "AGENTS.md",
456
+ description: "Repository-specific agent instructions if the project has them.",
457
+ mimeType: "text/markdown",
458
+ read: async () => ({ text: await readProjectFile(cwd, "AGENTS.md", 40_000) }),
459
+ },
460
+ ]
461
+ }
462
+
463
+ export function projectPrompts(): McpPrompt[] {
464
+ return [
465
+ {
466
+ name: "nifra_new_route",
467
+ description:
468
+ "Implement a new file route with the right routes/ filename, examples, and checks.",
469
+ arguments: [
470
+ { name: "path", description: 'URL path, e.g. "/users/:id".', required: true },
471
+ { name: "goal", description: "What the page should do.", required: false },
472
+ ],
473
+ handler: async (args) =>
474
+ promptText(
475
+ [
476
+ `Create a nifra page route for ${JSON.stringify(args.path ?? "/new-route")}.`,
477
+ args.goal ? `Goal: ${String(args.goal)}` : undefined,
478
+ 'Use `nifra_context` with `{ kind: "pages" }`, then `nifra_scaffold` for the exact file path.',
479
+ "If the stub is not writable for this framework, call `nifra_example` for a verified page/loader example before editing.",
480
+ "Verify with `nifra_render` for the route and finish with `nifra_check`.",
481
+ ]
482
+ .filter(Boolean)
483
+ .join("\n"),
484
+ ),
485
+ },
486
+ {
487
+ name: "nifra_add_endpoint",
488
+ description:
489
+ "Add or update a backend endpoint with schemas, typed-client usage, and verification.",
490
+ arguments: [
491
+ { name: "method", description: "HTTP method.", required: true },
492
+ { name: "path", description: 'API path, e.g. "/users/:id".', required: true },
493
+ { name: "goal", description: "What the endpoint should do.", required: false },
494
+ ],
495
+ handler: async (args) =>
496
+ promptText(
497
+ [
498
+ `Add a nifra backend endpoint: ${String(args.method ?? "GET").toUpperCase()} ${String(args.path ?? "/api")}.`,
499
+ args.goal ? `Goal: ${String(args.goal)}` : undefined,
500
+ "Read `nifra://routes` or call `nifra_routes` first so the new route fits the existing API shape.",
501
+ "Use route schemas for untrusted body/query input and declare `response` when the frontend consumes the shape.",
502
+ "Verify behavior with `nifra_run`, then run `nifra_check` and `nifra_test` for the touched area.",
503
+ ]
504
+ .filter(Boolean)
505
+ .join("\n"),
506
+ ),
507
+ },
508
+ {
509
+ name: "nifra_debug_drift",
510
+ description:
511
+ "Debug frontend/backend contract drift using the typed client and checker output.",
512
+ arguments: [
513
+ { name: "symptom", description: "The error or behavior to investigate.", required: false },
514
+ ],
515
+ handler: async (args) =>
516
+ promptText(
517
+ [
518
+ "Debug nifra contract drift.",
519
+ args.symptom ? `Symptom: ${String(args.symptom)}` : undefined,
520
+ "Start with `nifra_check`; use each diagnostic as the source of truth.",
521
+ "Read `nifra://routes` for the exact typed-client calls and compact body/query/response shapes.",
522
+ "Prefer `client<typeof app>` over hand-rolled internal `fetch`. Verify fixed endpoints with `nifra_run`.",
523
+ ]
524
+ .filter(Boolean)
525
+ .join("\n"),
526
+ ),
527
+ },
528
+ ]
529
+ }
530
+
531
+ export function projectFeatures(
532
+ cwd: string,
533
+ loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
534
+ ): McpServerFeatures {
535
+ return { resources: projectResources(cwd, loadAppCached), prompts: projectPrompts() }
536
+ }
537
+
538
+ /** Build the project-scoped tools for `cwd`. */
539
+ export function projectTools(
540
+ cwd: string,
541
+ loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
542
+ ): McpTool[] {
543
+ const warmRun = createWarmRunHandler(cwd)
544
+ return [
545
+ {
546
+ name: "nifra_context",
547
+ description:
548
+ "Get this nifra project's surface: API routes (backend.ts) with compact TypeScript-shaped contracts, page routes (routes/), and the framework conventions. Call it once unfiltered to learn the project; afterwards pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) for a cheap, narrow slice instead of the full dump.",
549
+ inputSchema: {
550
+ type: "object",
551
+ properties: {
552
+ path: {
553
+ type: "string",
554
+ description: "Only routes whose path/pattern starts with this prefix.",
555
+ },
556
+ kind: {
557
+ type: "string",
558
+ enum: ["api", "pages"],
559
+ description: "Limit to API routes or page routes.",
560
+ },
561
+ },
562
+ additionalProperties: false,
563
+ },
564
+ handler: async (args) => {
565
+ const filter = args as { path?: string; kind?: "api" | "pages" }
566
+ return describeProject(await loadAppCached(), filter)
567
+ },
568
+ },
569
+ {
570
+ name: "nifra_routes",
571
+ description:
572
+ "List this project's API routes as STRUCTURED JSON — each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ path: {
577
+ type: "string",
578
+ description: "Only routes whose path starts with this prefix (omit for all routes).",
579
+ },
580
+ },
581
+ additionalProperties: false,
582
+ },
583
+ handler: async (args) => {
584
+ const { routesToJson } = await import("./introspect.ts")
585
+ const { path } = args as { path?: string }
586
+ return JSON.stringify(routesToJson(await loadAppCached(), path), null, 2)
587
+ },
588
+ },
589
+ {
590
+ name: "nifra_openapi",
591
+ description:
592
+ '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. Frontend-only apps return a valid empty paths object.',
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ format: {
597
+ type: "string",
598
+ enum: ["json", "yaml"],
599
+ description: "Output format (default: json).",
600
+ },
601
+ },
602
+ additionalProperties: false,
603
+ },
604
+ handler: (args) => openApiHandler(args, loadAppCached),
605
+ },
606
+ {
607
+ name: "nifra_run",
608
+ description:
609
+ "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? }.",
610
+ inputSchema: {
611
+ type: "object",
612
+ properties: {
613
+ requests: {
614
+ type: "array",
615
+ items: {
616
+ type: "object",
617
+ properties: {
618
+ method: { type: "string" },
619
+ path: { type: "string" },
620
+ body: {},
621
+ headers: { type: "object" },
622
+ },
623
+ required: ["path"],
624
+ },
625
+ },
626
+ entry: {
627
+ type: "string",
628
+ description: "Backend entry file (default: backend.ts | app.ts).",
629
+ },
630
+ warm: {
631
+ type: "boolean",
632
+ description:
633
+ "Reuse a hot backend worker while source files are unchanged. Default false for maximum isolation.",
634
+ },
635
+ },
636
+ required: ["requests"],
637
+ additionalProperties: false,
638
+ },
639
+ handler: (args, context) => runHandler(cwd, args, context, warmRun),
640
+ },
641
+ {
642
+ name: "nifra_render",
643
+ description:
644
+ "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? }. Re-loaded in a fresh process each call.",
645
+ inputSchema: {
646
+ type: "object",
647
+ properties: {
648
+ requests: {
649
+ type: "array",
650
+ items: {
651
+ type: "object",
652
+ properties: {
653
+ path: { type: "string" },
654
+ headers: { type: "object" },
655
+ },
656
+ required: ["path"],
657
+ },
658
+ },
659
+ },
660
+ required: ["requests"],
661
+ additionalProperties: false,
662
+ },
663
+ handler: (args, context) => renderHandler(cwd, args, context),
664
+ },
665
+ {
666
+ name: "nifra_ws",
667
+ description:
668
+ '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.',
669
+ inputSchema: {
670
+ type: "object",
671
+ properties: {
672
+ path: {
673
+ type: "string",
674
+ description:
675
+ 'App-local WebSocket path, optionally with query, e.g. "/chat?token=secret".',
676
+ },
677
+ messages: {
678
+ type: "array",
679
+ items: { type: "string" },
680
+ description: "String frames to send after the socket opens (max 50).",
681
+ },
682
+ expectMessages: {
683
+ type: "number",
684
+ description:
685
+ "How many inbound messages must be observed before success (default: messages.length, or 1 with no sent messages; max 50).",
686
+ },
687
+ timeoutMs: {
688
+ type: "number",
689
+ description: "Bounded verification timeout in milliseconds (default 3000, max 30000).",
690
+ },
691
+ entry: {
692
+ type: "string",
693
+ description: "Backend entry file (default: backend.ts | app.ts).",
694
+ },
695
+ },
696
+ required: ["path"],
697
+ additionalProperties: false,
698
+ },
699
+ handler: (args, context) => wsHandler(cwd, args, context),
700
+ },
701
+ {
702
+ name: "nifra_test",
703
+ description:
704
+ "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.",
705
+ inputSchema: {
706
+ type: "object",
707
+ properties: {
708
+ pattern: {
709
+ type: "string",
710
+ description: "Optional test file/path pattern passed as an argv item to `bun test`.",
711
+ },
712
+ timeoutMs: {
713
+ type: "number",
714
+ description: "Timeout in milliseconds (default 30000, max 300000).",
715
+ },
716
+ },
717
+ additionalProperties: false,
718
+ },
719
+ handler: async (args, context) => {
720
+ const { collectTestResult } = await import("./test-tool.ts")
721
+ return JSON.stringify(
722
+ await collectTestResult(cwd, args, { signal: context.signal }),
723
+ null,
724
+ 2,
725
+ )
726
+ },
727
+ },
728
+ // nifra_docs + nifra_example are project-independent (corpus-backed); the shared factory keeps their
729
+ // definitions identical to the CLI HTTP server and the site's edge worker.
730
+ ...docsTools(loadDocsCorpus, loadExamplesCorpus),
731
+ {
732
+ name: "nifra_scaffold",
733
+ description:
734
+ '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).',
735
+ inputSchema: {
736
+ type: "object",
737
+ properties: {
738
+ path: {
739
+ type: "string",
740
+ description: 'The URL path for the new page route, e.g. "/users/:id" or "/blog/*slug".',
741
+ },
742
+ write: {
743
+ type: "boolean",
744
+ description:
745
+ "When true, create the file if a verified ready-to-write stub exists. Refuses overwrite.",
746
+ },
747
+ },
748
+ required: ["path"],
749
+ additionalProperties: false,
750
+ },
751
+ handler: async (args) => {
752
+ const { path, write } = args as { path?: string; write?: boolean }
753
+ if (typeof path !== "string" || path.length === 0) return "scaffold: `path` is required."
754
+ const { frameworkFromClientModule, renderScaffold, writeScaffoldRoute } = await import(
755
+ "./scaffold.ts"
756
+ )
757
+ const app = await loadAppCached()
758
+ const framework = frameworkFromClientModule(app.framework.clientModule)
759
+ if (write !== true) return renderScaffold(path, framework)
760
+ const result = await writeScaffoldRoute(cwd, path, framework)
761
+ const status = result.written
762
+ ? `Written: \`${result.file}\``
763
+ : `Not written: ${result.reason ?? "no write performed"}`
764
+ return `${status}\n\n${renderScaffold(path, framework)}`
765
+ },
766
+ },
767
+ {
768
+ name: "nifra_check",
769
+ description:
770
+ 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done — fix every diagnostic before finishing.',
771
+ inputSchema: {
772
+ type: "object",
773
+ properties: {
774
+ lintsOnly: {
775
+ type: "boolean",
776
+ description: "Skip tsc; run only the near-instant source lints (inner-loop mode).",
777
+ },
778
+ },
779
+ additionalProperties: false,
780
+ },
781
+ handler: async (args, context) => {
782
+ const { collectCheckResult } = await import("./check.ts")
783
+ const opts = args as { lintsOnly?: boolean }
784
+ return JSON.stringify(
785
+ await collectCheckResult(cwd, {
786
+ lintsOnly: opts.lintsOnly ?? false,
787
+ signal: context.signal,
788
+ }),
789
+ null,
790
+ 2,
791
+ )
792
+ },
793
+ },
794
+ {
795
+ name: "nifra_doctor",
796
+ description:
797
+ "Check this project for packages imported in source but NOT declared in package.json — the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
798
+ inputSchema: {
799
+ type: "object",
800
+ properties: {
801
+ autoFix: {
802
+ type: "boolean",
803
+ description:
804
+ "When true, write safe package.json fixes using only locally inferred versions. Does not run install or use the network.",
805
+ },
806
+ },
807
+ additionalProperties: false,
808
+ },
809
+ handler: async (args) => {
810
+ const opts = args as { autoFix?: boolean }
811
+ const { applyDoctorAutoFix, collectDoctorResult } = await import("./doctor.ts")
812
+ return JSON.stringify(
813
+ opts.autoFix === true ? await applyDoctorAutoFix(cwd) : await collectDoctorResult(cwd),
814
+ null,
815
+ 2,
816
+ )
817
+ },
818
+ },
819
+ ]
820
+ }
821
+
822
+ /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
823
+ export async function runMcpServer(cwd: string, version: string): Promise<void> {
824
+ const loadAppCached = createCachedAppLoader(cwd)
825
+ const tools = projectTools(cwd, loadAppCached)
826
+ const features = projectFeatures(cwd, loadAppCached)
827
+ const serverInfo = { name: "nifra", version }
828
+ const state = createMcpProtocolState()
829
+ const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
830
+ process.stdout.write(`${JSON.stringify(message)}\n`)
831
+ }
832
+ const dispatch = async (message: JsonRpcRequest): Promise<void> => {
833
+ const response = await handleRpc(message, tools, serverInfo, features, {
834
+ state,
835
+ sendNotification: send,
836
+ })
837
+ if (response) send(response)
838
+ }
839
+
840
+ const decoder = new TextDecoder()
841
+ let buffer = ""
842
+ for await (const chunk of Bun.stdin.stream()) {
843
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
844
+ let nl = buffer.indexOf("\n")
845
+ while (nl !== -1) {
846
+ const line = buffer.slice(0, nl).trim()
847
+ buffer = buffer.slice(nl + 1)
848
+ nl = buffer.indexOf("\n")
849
+ if (line === "") continue
850
+ let message: JsonRpcRequest
851
+ try {
852
+ message = JSON.parse(line)
853
+ } catch {
854
+ send(rpcError(null, -32700, "parse error"))
855
+ continue
856
+ }
857
+ void dispatch(message).catch((err) => {
858
+ const msg = err instanceof Error ? err.message : String(err)
859
+ send(rpcError(message.id ?? null, -32603, msg))
860
+ })
861
+ }
862
+ }
863
+ }