@nifrajs/cli 2.2.0 → 2.4.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 +25 -25
- package/dist/assure.d.ts +2 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +17 -10
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +7 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +68 -16
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +61 -22
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +336 -87
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +30 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +148 -39
- package/dist/cli.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.js +7 -7
- package/dist/docs-search.d.ts +3 -3
- package/dist/docs-search.js +6 -6
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +12 -8
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +5 -5
- package/dist/examples.js +5 -5
- package/dist/init-agents.d.ts +14 -14
- package/dist/init-agents.js +14 -14
- package/dist/internal/typescript-import.d.ts +24 -0
- package/dist/internal/typescript-import.d.ts.map +1 -0
- package/dist/internal/typescript-import.js +32 -0
- package/dist/internal/typescript-import.js.map +1 -0
- package/dist/introspect.d.ts +12 -12
- package/dist/introspect.js +24 -24
- package/dist/learn.d.ts +32 -0
- package/dist/learn.d.ts.map +1 -0
- package/dist/learn.js +127 -0
- package/dist/learn.js.map +1 -0
- package/dist/levels-tool.d.ts +8 -8
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -30
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +6 -6
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +15 -7
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +4 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts +14 -0
- package/dist/mcp-app-tool.d.ts.map +1 -0
- package/dist/mcp-app-tool.js +80 -0
- package/dist/mcp-app-tool.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +25 -9
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +14 -10
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +30 -16
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-protocol.d.ts +2 -2
- package/dist/mcp-protocol.js +2 -2
- package/dist/mcp-render.d.ts +3 -3
- package/dist/mcp-render.js +6 -6
- package/dist/mcp-run.d.ts +2 -2
- package/dist/mcp-run.js +2 -2
- package/dist/mcp.d.ts +28 -14
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +263 -45
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +1 -1
- package/dist/openapi-tool.js +2 -2
- package/dist/pipeline-guard.d.ts +1 -1
- package/dist/pipeline-guard.js +1 -1
- package/dist/port.d.ts +9 -9
- package/dist/port.js +27 -27
- package/dist/recipes/1.8.0.d.ts +2 -2
- package/dist/recipes/1.8.0.js +5 -5
- package/dist/recipes/index.d.ts +2 -2
- package/dist/recipes/index.js +1 -1
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +8 -8
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.d.ts.map +1 -0
- package/dist/sync-routes.js +55 -0
- package/dist/sync-routes.js.map +1 -0
- package/dist/types-search.d.ts +5 -5
- package/dist/types-search.js +7 -7
- package/dist/upgrade.d.ts +20 -9
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +117 -19
- package/dist/upgrade.js.map +1 -1
- package/dist/verification.d.ts +51 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +80 -0
- package/dist/verification.js.map +1 -0
- package/docs/examples.json +174 -90
- package/docs/llms-full.txt +1771 -748
- package/docs/types.json +2027 -3089
- package/package.json +22 -11
- package/src/assure.ts +17 -18
- package/src/capabilities-tool.ts +82 -16
- package/src/check.ts +410 -104
- package/src/cli.ts +161 -41
- package/src/diff-tool.ts +8 -8
- package/src/docs-search.ts +6 -6
- package/src/doctor.ts +18 -10
- package/src/examples.ts +7 -7
- package/src/init-agents.ts +17 -17
- package/src/internal/typescript-import.ts +33 -0
- package/src/introspect.ts +28 -28
- package/src/learn.ts +152 -0
- package/src/levels-tool.ts +37 -31
- package/src/load.ts +21 -12
- package/src/manifest-tool.ts +4 -2
- package/src/mcp-app-tool.ts +86 -0
- package/src/mcp-docs-tools.ts +26 -9
- package/src/mcp-http.ts +31 -18
- package/src/mcp-protocol.ts +2 -2
- package/src/mcp-render.ts +6 -6
- package/src/mcp-run.ts +2 -2
- package/src/mcp.ts +319 -45
- package/src/openapi-tool.ts +2 -2
- package/src/pipeline-guard.ts +1 -1
- package/src/port.ts +31 -31
- package/src/recipes/1.8.0.ts +5 -5
- package/src/recipes/index.ts +2 -2
- package/src/scaffold.ts +8 -8
- package/src/sync-routes.ts +69 -0
- package/src/types-search.ts +8 -8
- package/src/upgrade.ts +137 -19
- package/src/verification.ts +123 -0
package/src/mcp.ts
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra mcp`
|
|
2
|
+
* `nifra mcp` - a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
|
|
3
3
|
* act on a nifra project, not just read about it:
|
|
4
4
|
*
|
|
5
|
-
* - `nifra_context`
|
|
6
|
-
* - `nifra_run`
|
|
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
7
|
* (status, body, errors). The write → run → see-the-failure → fix loop, powered by `@nifrajs/runner`.
|
|
8
8
|
* Each call runs the backend in a FRESH subprocess, so it reflects the agent's latest edits.
|
|
9
|
-
* - `nifra_render`
|
|
9
|
+
* - `nifra_render` - SSR a page route through the project's web app; returns the rendered HTML (the
|
|
10
10
|
* page half of `nifra_run`). Fresh subprocess per call; no build needed.
|
|
11
|
-
* - `nifra_ws`
|
|
12
|
-
* - `nifra_docs`
|
|
13
|
-
* - `nifra_example`
|
|
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
14
|
* so it can't hallucinate a drifted nifra API).
|
|
15
|
-
* - `
|
|
16
|
-
*
|
|
17
|
-
* - `
|
|
15
|
+
* - `nifra_learn` - the guided, ordered path to build a nifra app end to end (create → deploy), each
|
|
16
|
+
* step naming the tool that emits the correct artifact and how to verify it.
|
|
17
|
+
* - `nifra_scaffold`- map a URL path to the correct `routes/` file + a contract-correct page stub.
|
|
18
|
+
* - `nifra_check` - the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
|
|
19
|
+
* - `nifra_assure` - route classification + enforcement-evidence gate.
|
|
18
20
|
* - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
|
|
19
21
|
* project actually proves, and why each level it misses does not hold.
|
|
20
|
-
* - `nifra_doctor`
|
|
22
|
+
* - `nifra_doctor` - package.json dependency drift detector, with safe local-version auto-fix.
|
|
23
|
+
* - `nifra_explain` - resolve an error (pasted, or the dev server's last) into a structured
|
|
24
|
+
* diagnostic: stable code, a codeframe in the user's source, and the recognised cause + fix.
|
|
25
|
+
* - `nifra_inspect` - read the running dev server's recent request traces (method/path/status/
|
|
26
|
+
* duration/ISR) from the DevTools plugin: what your requests ACTUALLY did, not a guess.
|
|
21
27
|
*
|
|
22
28
|
* Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
|
|
23
29
|
* project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
|
|
24
|
-
* standard MCP progress notifications and request cancellation
|
|
30
|
+
* standard MCP progress notifications and request cancellation - no SDK dependency, the same
|
|
25
31
|
* minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
|
|
26
32
|
* module is the I/O shell (stdin loop, tool wiring, the run subprocess).
|
|
27
33
|
*/
|
|
@@ -75,6 +81,67 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
|
|
|
75
81
|
*/
|
|
76
82
|
const CHILD_TIMEOUT_MS = 30_000
|
|
77
83
|
|
|
84
|
+
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
85
|
+
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
86
|
+
export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
|
|
87
|
+
export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
|
|
88
|
+
|
|
89
|
+
/** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
|
|
90
|
+
export function validateLocalPort(port: unknown): number | undefined {
|
|
91
|
+
return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
|
|
92
|
+
? port
|
|
93
|
+
: undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read a dev-tool response without allowing an untrusted localhost service to allocate unbounded memory. */
|
|
97
|
+
export async function readBoundedResponse(
|
|
98
|
+
response: Response,
|
|
99
|
+
maxBytes = LOCAL_TOOL_MAX_RESPONSE_BYTES,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
const declared = response.headers.get("content-length")
|
|
102
|
+
if (declared !== null) {
|
|
103
|
+
const length = Number(declared)
|
|
104
|
+
if (Number.isFinite(length) && length > maxBytes)
|
|
105
|
+
throw new Error("response exceeded the size limit")
|
|
106
|
+
}
|
|
107
|
+
if (response.body === null) return ""
|
|
108
|
+
const reader = response.body.getReader()
|
|
109
|
+
const chunks: Uint8Array[] = []
|
|
110
|
+
let total = 0
|
|
111
|
+
try {
|
|
112
|
+
for (;;) {
|
|
113
|
+
const { done, value } = await reader.read()
|
|
114
|
+
if (done) break
|
|
115
|
+
if (value === undefined) continue
|
|
116
|
+
total += value.byteLength
|
|
117
|
+
if (total > maxBytes) {
|
|
118
|
+
await reader.cancel()
|
|
119
|
+
throw new Error("response exceeded the size limit")
|
|
120
|
+
}
|
|
121
|
+
chunks.push(value)
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
reader.releaseLock()
|
|
125
|
+
}
|
|
126
|
+
const bytes = new Uint8Array(total)
|
|
127
|
+
let offset = 0
|
|
128
|
+
for (const chunk of chunks) {
|
|
129
|
+
bytes.set(chunk, offset)
|
|
130
|
+
offset += chunk.byteLength
|
|
131
|
+
}
|
|
132
|
+
return new TextDecoder().decode(bytes)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const notNifraResponse = (service: string): string =>
|
|
136
|
+
JSON.stringify(
|
|
137
|
+
{
|
|
138
|
+
code: "NIFRA_NOT_DEV_SERVER",
|
|
139
|
+
message: `The service on this port is not a Nifra ${service} endpoint.`,
|
|
140
|
+
},
|
|
141
|
+
null,
|
|
142
|
+
2,
|
|
143
|
+
)
|
|
144
|
+
|
|
78
145
|
const timeoutMessage = (label: string, ms: number): string =>
|
|
79
146
|
`${label} timed out after ${ms / 1000}s and was terminated.\n` +
|
|
80
147
|
`The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
|
|
@@ -159,7 +226,7 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
|
|
|
159
226
|
|
|
160
227
|
/** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
|
|
161
228
|
* reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
|
|
162
|
-
* powers both `nifra_run warm` and `nifra_render warm`
|
|
229
|
+
* powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
|
|
163
230
|
* cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
|
|
164
231
|
* replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
|
|
165
232
|
* concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
|
|
@@ -217,7 +284,7 @@ export class WarmWorker {
|
|
|
217
284
|
// Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
|
|
218
285
|
// across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
|
|
219
286
|
// process here would reject every OTHER in-flight request via the `exited` handler and force a
|
|
220
|
-
// cold rebuild. Leave it hot
|
|
287
|
+
// cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
|
|
221
288
|
this.pending.delete(id)
|
|
222
289
|
clearTimeout(timer)
|
|
223
290
|
const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
|
|
@@ -297,7 +364,7 @@ export class WarmWorker {
|
|
|
297
364
|
|
|
298
365
|
/** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
|
|
299
366
|
* subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
|
|
300
|
-
* `nifra_render` (`child: "mcp-render"`, `label: "render"`)
|
|
367
|
+
* `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
|
|
301
368
|
* message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
|
|
302
369
|
function createWarmHandler(
|
|
303
370
|
child: "mcp-run" | "mcp-render",
|
|
@@ -616,7 +683,7 @@ export function projectFeatures(
|
|
|
616
683
|
}
|
|
617
684
|
|
|
618
685
|
/**
|
|
619
|
-
* Resolve an optional `dir` tool argument
|
|
686
|
+
* Resolve an optional `dir` tool argument - a subdirectory of the project root the caller wants to scope a
|
|
620
687
|
* check/test to (e.g. `nifra check` on `app/` when the MCP server's root is a monorepo). Returns the
|
|
621
688
|
* absolute target, or `null` if `dir` escapes the root (a path-traversal guard: no `..` out, no absolute
|
|
622
689
|
* path elsewhere). `undefined`/empty → the root itself.
|
|
@@ -630,7 +697,7 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
|
|
|
630
697
|
/** Consistent error string for a `dir` that escapes the project root. */
|
|
631
698
|
function dirError(dir: string | undefined): string {
|
|
632
699
|
return JSON.stringify(
|
|
633
|
-
{ ok: false, error: `dir must be a subdirectory of the project root
|
|
700
|
+
{ ok: false, error: `dir must be a subdirectory of the project root - "${dir}" escapes it.` },
|
|
634
701
|
null,
|
|
635
702
|
2,
|
|
636
703
|
)
|
|
@@ -647,7 +714,7 @@ export function projectTools(
|
|
|
647
714
|
{
|
|
648
715
|
name: "nifra_context",
|
|
649
716
|
description:
|
|
650
|
-
"Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer
|
|
717
|
+
"Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer - cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
|
|
651
718
|
inputSchema: {
|
|
652
719
|
type: "object",
|
|
653
720
|
properties: {
|
|
@@ -668,10 +735,180 @@ export function projectTools(
|
|
|
668
735
|
return describeProject(await loadAppCached(), filter)
|
|
669
736
|
},
|
|
670
737
|
},
|
|
738
|
+
{
|
|
739
|
+
name: "nifra_explain",
|
|
740
|
+
description:
|
|
741
|
+
"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.",
|
|
742
|
+
inputSchema: {
|
|
743
|
+
type: "object",
|
|
744
|
+
properties: {
|
|
745
|
+
error: {
|
|
746
|
+
type: "string",
|
|
747
|
+
description:
|
|
748
|
+
"The error message to explain (copy it from nifra_run/nifra_test output or a failing build).",
|
|
749
|
+
},
|
|
750
|
+
stack: {
|
|
751
|
+
type: "string",
|
|
752
|
+
description:
|
|
753
|
+
"The error's stack trace, if you have it - enables the source codeframe and the top user frame.",
|
|
754
|
+
},
|
|
755
|
+
name: {
|
|
756
|
+
type: "string",
|
|
757
|
+
description:
|
|
758
|
+
"The error's class name (e.g. TypeError, SchemaError), if known - sharpens classification.",
|
|
759
|
+
},
|
|
760
|
+
port: {
|
|
761
|
+
type: "number",
|
|
762
|
+
description:
|
|
763
|
+
"Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
additionalProperties: false,
|
|
767
|
+
},
|
|
768
|
+
handler: async (args) => {
|
|
769
|
+
const { error, stack, name, port } = args as {
|
|
770
|
+
error?: string
|
|
771
|
+
stack?: string
|
|
772
|
+
name?: string
|
|
773
|
+
port?: number
|
|
774
|
+
}
|
|
775
|
+
if (error !== undefined || stack !== undefined) {
|
|
776
|
+
const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
|
|
777
|
+
const e = new Error(error ?? "")
|
|
778
|
+
if (name !== undefined) e.name = name
|
|
779
|
+
if (stack !== undefined) e.stack = stack
|
|
780
|
+
return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
|
|
781
|
+
}
|
|
782
|
+
if (port !== undefined) {
|
|
783
|
+
const validPort = validateLocalPort(port)
|
|
784
|
+
if (validPort === undefined) {
|
|
785
|
+
return JSON.stringify(
|
|
786
|
+
{ code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
|
|
787
|
+
null,
|
|
788
|
+
2,
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
|
|
792
|
+
try {
|
|
793
|
+
const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
|
|
794
|
+
signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
|
|
795
|
+
})
|
|
796
|
+
if (res.headers.get("x-nifra-diagnostic") !== "true")
|
|
797
|
+
return notNifraResponse("diagnostic")
|
|
798
|
+
if (!res.ok) {
|
|
799
|
+
return JSON.stringify(
|
|
800
|
+
{
|
|
801
|
+
code: "NIFRA_NONE",
|
|
802
|
+
message: `dev server at :${validPort} returned ${res.status}`,
|
|
803
|
+
},
|
|
804
|
+
null,
|
|
805
|
+
2,
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
return await readBoundedResponse(res)
|
|
809
|
+
} catch (cause) {
|
|
810
|
+
return JSON.stringify(
|
|
811
|
+
{
|
|
812
|
+
code: "NIFRA_NONE",
|
|
813
|
+
message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
814
|
+
},
|
|
815
|
+
null,
|
|
816
|
+
2,
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return JSON.stringify(
|
|
821
|
+
{
|
|
822
|
+
code: "NIFRA_NONE",
|
|
823
|
+
message:
|
|
824
|
+
"Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
|
|
825
|
+
},
|
|
826
|
+
null,
|
|
827
|
+
2,
|
|
828
|
+
)
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
name: "nifra_inspect",
|
|
833
|
+
description:
|
|
834
|
+
"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).",
|
|
835
|
+
inputSchema: {
|
|
836
|
+
type: "object",
|
|
837
|
+
properties: {
|
|
838
|
+
port: { type: "number", description: "The running dev server's port." },
|
|
839
|
+
path: { type: "string", description: "Only traces whose path starts with this prefix." },
|
|
840
|
+
limit: { type: "number", description: "Return only the most recent N traces." },
|
|
841
|
+
},
|
|
842
|
+
required: ["port"],
|
|
843
|
+
additionalProperties: false,
|
|
844
|
+
},
|
|
845
|
+
handler: async (args) => {
|
|
846
|
+
const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
|
|
847
|
+
if (port === undefined) {
|
|
848
|
+
return JSON.stringify(
|
|
849
|
+
{
|
|
850
|
+
events: [],
|
|
851
|
+
note: "Pass `port` - the running dev server whose request traces to read.",
|
|
852
|
+
},
|
|
853
|
+
null,
|
|
854
|
+
2,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
const validPort = validateLocalPort(port)
|
|
859
|
+
if (validPort === undefined) {
|
|
860
|
+
return JSON.stringify(
|
|
861
|
+
{
|
|
862
|
+
events: [],
|
|
863
|
+
code: "NIFRA_INVALID_PORT",
|
|
864
|
+
note: "port must be an integer from 1 to 65535.",
|
|
865
|
+
},
|
|
866
|
+
null,
|
|
867
|
+
2,
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
|
|
871
|
+
if (path !== undefined) url.searchParams.set("path", path)
|
|
872
|
+
if (limit !== undefined) url.searchParams.set("limit", String(limit))
|
|
873
|
+
const res = await fetch(url, {
|
|
874
|
+
signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
|
|
875
|
+
})
|
|
876
|
+
if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
|
|
877
|
+
if (res.status === 404) {
|
|
878
|
+
return JSON.stringify(
|
|
879
|
+
{
|
|
880
|
+
events: [],
|
|
881
|
+
note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
|
|
882
|
+
},
|
|
883
|
+
null,
|
|
884
|
+
2,
|
|
885
|
+
)
|
|
886
|
+
}
|
|
887
|
+
if (!res.ok) {
|
|
888
|
+
return JSON.stringify(
|
|
889
|
+
{ events: [], note: `DevTools state returned ${res.status}.` },
|
|
890
|
+
null,
|
|
891
|
+
2,
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
return await readBoundedResponse(res)
|
|
895
|
+
} catch (cause) {
|
|
896
|
+
const validPort = validateLocalPort(port)
|
|
897
|
+
return JSON.stringify(
|
|
898
|
+
{
|
|
899
|
+
events: [],
|
|
900
|
+
note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
901
|
+
},
|
|
902
|
+
null,
|
|
903
|
+
2,
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
},
|
|
671
908
|
{
|
|
672
909
|
name: "nifra_routes",
|
|
673
910
|
description:
|
|
674
|
-
"List this project's API routes as STRUCTURED JSON
|
|
911
|
+
"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.",
|
|
675
912
|
inputSchema: {
|
|
676
913
|
type: "object",
|
|
677
914
|
properties: {
|
|
@@ -851,7 +1088,7 @@ export function projectTools(
|
|
|
851
1088
|
{
|
|
852
1089
|
name: "nifra_scaffold",
|
|
853
1090
|
description:
|
|
854
|
-
'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong
|
|
1091
|
+
'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).',
|
|
855
1092
|
inputSchema: {
|
|
856
1093
|
type: "object",
|
|
857
1094
|
properties: {
|
|
@@ -887,7 +1124,7 @@ export function projectTools(
|
|
|
887
1124
|
{
|
|
888
1125
|
name: "nifra_check",
|
|
889
1126
|
description:
|
|
890
|
-
'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
|
|
1127
|
+
'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.',
|
|
891
1128
|
inputSchema: {
|
|
892
1129
|
type: "object",
|
|
893
1130
|
properties: {
|
|
@@ -1086,7 +1323,7 @@ export function projectTools(
|
|
|
1086
1323
|
{
|
|
1087
1324
|
name: "nifra_doctor",
|
|
1088
1325
|
description:
|
|
1089
|
-
"Check this project for packages imported in source but NOT declared in package.json
|
|
1326
|
+
"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.",
|
|
1090
1327
|
inputSchema: {
|
|
1091
1328
|
type: "object",
|
|
1092
1329
|
properties: {
|
|
@@ -1259,6 +1496,54 @@ function namespaceForApp(
|
|
|
1259
1496
|
}
|
|
1260
1497
|
}
|
|
1261
1498
|
|
|
1499
|
+
/**
|
|
1500
|
+
* Merge the backend-derived resources/prompts into `base` - and keep a project that cannot be loaded
|
|
1501
|
+
* from deciding whether the server starts at all.
|
|
1502
|
+
*
|
|
1503
|
+
* Resources and prompts are OPTIONAL capabilities extracted from the app's backend, so this load is a
|
|
1504
|
+
* best-effort enrichment. It was awaited unguarded, which made it a hard boot requirement: `loadApp`
|
|
1505
|
+
* needs a web config (`adapter` + `clientModule`) and a `routes/` directory, and a backend-only
|
|
1506
|
+
* project - the shape `create-nifra`'s DEFAULT template produces - has neither. On those projects the
|
|
1507
|
+
* throw escaped before the JSON-RPC loop began, so the server wrote nothing and exited. `nifra
|
|
1508
|
+
* init-agents` would happily register an `.mcp.json` pointing at a server that could never start, and
|
|
1509
|
+
* every tool went with it, including the twelve that never touch the app (docs, examples, types,
|
|
1510
|
+
* check, doctor, levels, test).
|
|
1511
|
+
*
|
|
1512
|
+
* Degrading costs only the backend resources/prompts. Tools that genuinely need the app still fail,
|
|
1513
|
+
* but per call, with their own message - which is a diagnosis instead of a silence.
|
|
1514
|
+
*/
|
|
1515
|
+
async function backendFeatures(
|
|
1516
|
+
loader: () => Promise<LoadedApp>,
|
|
1517
|
+
base: McpServerFeatures,
|
|
1518
|
+
): Promise<McpServerFeatures> {
|
|
1519
|
+
const resources = [...(base.resources ?? [])]
|
|
1520
|
+
const prompts = [...(base.prompts ?? [])]
|
|
1521
|
+
try {
|
|
1522
|
+
const app = await loader()
|
|
1523
|
+
resources.push(...extractBackendResources(app.backend))
|
|
1524
|
+
prompts.push(...extractBackendPrompts(app.backend))
|
|
1525
|
+
} catch {
|
|
1526
|
+
// Not loadable here (no web config, or the config itself throws). The server still serves.
|
|
1527
|
+
}
|
|
1528
|
+
return { resources, prompts }
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* The MCP tools the app itself declares via `app.tool(...)`, or none when the app cannot be loaded.
|
|
1533
|
+
*
|
|
1534
|
+
* Same reasoning as {@link backendFeatures}, on the hotter path: this ran on EVERY JSON-RPC message,
|
|
1535
|
+
* so an unloadable project failed `initialize` itself with a `-32603` and the session never opened.
|
|
1536
|
+
* An app's own tools are an extension of the built-in set, so their absence must not withdraw the
|
|
1537
|
+
* built-ins - a backend-only project still gets docs, examples, types, check, doctor, levels and test.
|
|
1538
|
+
*/
|
|
1539
|
+
async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTool[]> {
|
|
1540
|
+
try {
|
|
1541
|
+
return extractBackendTools((await loader()).backend)
|
|
1542
|
+
} catch {
|
|
1543
|
+
return []
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1262
1547
|
/** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
|
|
1263
1548
|
export async function runMcpServer(cwd: string, version: string): Promise<void> {
|
|
1264
1549
|
let features: McpServerFeatures
|
|
@@ -1270,24 +1555,18 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
|
|
|
1270
1555
|
const allPrompts: McpPrompt[] = []
|
|
1271
1556
|
for (const { name, cwd: appCwd } of appEntries) {
|
|
1272
1557
|
const loader = createCachedAppLoader(appCwd)
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
})
|
|
1558
|
+
const ns = namespaceForApp(
|
|
1559
|
+
name,
|
|
1560
|
+
[],
|
|
1561
|
+
await backendFeatures(loader, projectFeatures(appCwd, loader)),
|
|
1562
|
+
)
|
|
1279
1563
|
allResources.push(...(ns.features.resources ?? []))
|
|
1280
1564
|
allPrompts.push(...(ns.features.prompts ?? []))
|
|
1281
1565
|
}
|
|
1282
1566
|
features = { resources: allResources, prompts: allPrompts }
|
|
1283
1567
|
} else {
|
|
1284
1568
|
const loadAppCached = createCachedAppLoader(cwd)
|
|
1285
|
-
|
|
1286
|
-
const app = await loadAppCached()
|
|
1287
|
-
features = {
|
|
1288
|
-
resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
|
|
1289
|
-
prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
|
|
1290
|
-
}
|
|
1569
|
+
features = await backendFeatures(loadAppCached, projectFeatures(cwd, loadAppCached))
|
|
1291
1570
|
}
|
|
1292
1571
|
const loadAppCached = createCachedAppLoader(cwd)
|
|
1293
1572
|
const serverInfo = { name: "nifra", version }
|
|
@@ -1302,21 +1581,16 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
|
|
|
1302
1581
|
const allTools: McpTool[] = []
|
|
1303
1582
|
for (const { name, cwd: appCwd } of appEntries) {
|
|
1304
1583
|
const loader = createCachedAppLoader(appCwd)
|
|
1305
|
-
const
|
|
1306
|
-
const
|
|
1307
|
-
const backendTools = extractBackendTools(app.backend)
|
|
1308
|
-
const ns = namespaceForApp(name, [...baseTools, ...backendTools], {
|
|
1309
|
-
resources: [],
|
|
1310
|
-
prompts: [],
|
|
1311
|
-
})
|
|
1584
|
+
const tools = [...projectTools(appCwd, loader), ...(await appDeclaredTools(loader))]
|
|
1585
|
+
const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
|
|
1312
1586
|
allTools.push(...ns.tools)
|
|
1313
1587
|
}
|
|
1314
1588
|
activeTools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
|
|
1315
1589
|
} else {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1590
|
+
activeTools = [
|
|
1591
|
+
...projectTools(cwd, loadAppCached),
|
|
1592
|
+
...(await appDeclaredTools(loadAppCached)),
|
|
1593
|
+
]
|
|
1320
1594
|
}
|
|
1321
1595
|
|
|
1322
1596
|
const response = await handleRpc(message, activeTools, serverInfo, features, {
|
package/src/openapi-tool.ts
CHANGED
|
@@ -45,7 +45,7 @@ function toYaml(value: unknown, depth = 0): string {
|
|
|
45
45
|
* Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
|
|
46
46
|
* easier for agents and API tooling to handle than a tool error.
|
|
47
47
|
*
|
|
48
|
-
* `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix
|
|
48
|
+
* `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix -
|
|
49
49
|
* mirroring `nifra_routes`'s `path` filter so a large backend needn't return the whole document. The keys
|
|
50
50
|
* are templated (`/users/{id}`), so a prefix up to the first param (`/api/orders`) matches `/api/orders`
|
|
51
51
|
* and `/api/orders/{id}` alike. `components.schemas` is left intact: it's keyed by schema name (not path),
|
|
@@ -57,7 +57,7 @@ export function renderOpenApi(app: LoadedApp, format: OpenApiFormat, pathPrefix?
|
|
|
57
57
|
const input = hasRoutesMethod(app.backend) ? app.backend : {}
|
|
58
58
|
const generated = toOpenAPI(input, { title, version: "1.0.0" })
|
|
59
59
|
// Narrow to operations under the prefix, rebuilding the doc (its `paths` is readonly). `components`
|
|
60
|
-
// stays
|
|
60
|
+
// stays - see the doc comment for why the schema set isn't recomputed.
|
|
61
61
|
const document =
|
|
62
62
|
pathPrefix !== undefined && pathPrefix !== ""
|
|
63
63
|
? {
|
package/src/pipeline-guard.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pipeline separation guard
|
|
2
|
+
* Pipeline separation guard - one bundler owns a phase, never two.
|
|
3
3
|
*
|
|
4
4
|
* nifra supports both Vite and Bun, and the config keeps them in separate slots: `vitePlugins` for
|
|
5
5
|
* the Vite pipeline, `clientPlugins`/`serverPlugins` for the Bun one. Nothing enforces the split, so
|