@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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/check.d.ts +100 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +690 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +208 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs-search.d.ts +30 -0
- package/dist/docs-search.d.ts.map +1 -0
- package/dist/docs-search.js +94 -0
- package/dist/docs-search.js.map +1 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +291 -0
- package/dist/doctor.js.map +1 -0
- package/dist/examples.d.ts +30 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/introspect.d.ts +64 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +243 -0
- package/dist/introspect.js.map +1 -0
- package/dist/load.d.ts +51 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +70 -0
- package/dist/load.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +13 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -0
- package/dist/mcp-docs-tools.js +63 -0
- package/dist/mcp-docs-tools.js.map +1 -0
- package/dist/mcp-http.d.ts +43 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +136 -0
- package/dist/mcp-http.js.map +1 -0
- package/dist/mcp-protocol.d.ts +97 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +248 -0
- package/dist/mcp-protocol.js.map +1 -0
- package/dist/mcp-render.d.ts +17 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-render.js +73 -0
- package/dist/mcp-render.js.map +1 -0
- package/dist/mcp-run.d.ts +24 -0
- package/dist/mcp-run.d.ts.map +1 -0
- package/dist/mcp-run.js +142 -0
- package/dist/mcp-run.js.map +1 -0
- package/dist/mcp-ws.d.ts +26 -0
- package/dist/mcp-ws.d.ts.map +1 -0
- package/dist/mcp-ws.js +258 -0
- package/dist/mcp-ws.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +712 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openapi-tool.d.ts +6 -0
- package/dist/openapi-tool.d.ts.map +1 -0
- package/dist/openapi-tool.js +48 -0
- package/dist/openapi-tool.js.map +1 -0
- package/dist/scaffold.d.ts +35 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +140 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/search-terms.d.ts +17 -0
- package/dist/search-terms.d.ts.map +1 -0
- package/dist/search-terms.js +82 -0
- package/dist/search-terms.js.map +1 -0
- package/dist/test-tool.d.ts +29 -0
- package/dist/test-tool.d.ts.map +1 -0
- package/dist/test-tool.js +150 -0
- package/dist/test-tool.js.map +1 -0
- package/docs/examples.json +314 -0
- package/docs/llms-full.txt +4325 -0
- package/package.json +58 -0
- package/src/check.ts +790 -0
- package/src/cli.ts +237 -0
- package/src/docs-search.ts +111 -0
- package/src/doctor.ts +349 -0
- package/src/examples.ts +89 -0
- package/src/introspect.ts +284 -0
- package/src/load.ts +129 -0
- package/src/mcp-docs-tools.ts +72 -0
- package/src/mcp-http.ts +155 -0
- package/src/mcp-protocol.ts +326 -0
- package/src/mcp-render.ts +78 -0
- package/src/mcp-run.ts +153 -0
- package/src/mcp-ws.ts +328 -0
- package/src/mcp.ts +863 -0
- package/src/openapi-tool.ts +51 -0
- package/src/scaffold.ts +164 -0
- package/src/search-terms.ts +89 -0
- package/src/test-tool.ts +181 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure MCP (Model Context Protocol) JSON-RPC dispatch — no I/O, no `Bun.*`, no side effects, so it
|
|
3
|
+
* unit-tests cleanly. {@link runMcpServer} in `./mcp.ts` wires this to stdin/stdout; the tools are
|
|
4
|
+
* injected, so the protocol logic is exercised without spawning or reading streams.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PROTOCOL_VERSION = "2024-11-05"
|
|
8
|
+
|
|
9
|
+
type RequestId = string | number
|
|
10
|
+
type JsonRpcId = RequestId | null
|
|
11
|
+
type ProgressToken = string | number
|
|
12
|
+
|
|
13
|
+
export interface McpToolContext {
|
|
14
|
+
readonly signal: AbortSignal
|
|
15
|
+
readonly requestId: JsonRpcId
|
|
16
|
+
readonly progressToken?: ProgressToken
|
|
17
|
+
readonly reportProgress: (progress: number, total?: number) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A tool the agent can call. `handler` returns the text shown to the agent. */
|
|
21
|
+
export interface McpTool {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly description: string
|
|
24
|
+
readonly inputSchema: Record<string, unknown>
|
|
25
|
+
readonly handler: (args: Record<string, unknown>, context: McpToolContext) => Promise<string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface McpResource {
|
|
29
|
+
readonly uri: string
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly description?: string
|
|
32
|
+
readonly mimeType?: string
|
|
33
|
+
readonly read: () => Promise<{ readonly text: string; readonly mimeType?: string }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpPromptMessage {
|
|
37
|
+
readonly role: "user" | "assistant"
|
|
38
|
+
readonly content: { readonly type: "text"; readonly text: string }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface McpPrompt {
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly description: string
|
|
44
|
+
readonly arguments?: readonly {
|
|
45
|
+
readonly name: string
|
|
46
|
+
readonly description?: string
|
|
47
|
+
readonly required?: boolean
|
|
48
|
+
}[]
|
|
49
|
+
readonly handler: (args: Record<string, unknown>) => Promise<readonly McpPromptMessage[]>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface McpServerFeatures {
|
|
53
|
+
readonly resources?: readonly McpResource[]
|
|
54
|
+
readonly prompts?: readonly McpPrompt[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface JsonRpcRequest {
|
|
58
|
+
readonly jsonrpc?: string
|
|
59
|
+
readonly id?: JsonRpcId
|
|
60
|
+
readonly method?: string
|
|
61
|
+
readonly params?: Record<string, unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface JsonRpcNotification {
|
|
65
|
+
readonly jsonrpc: "2.0"
|
|
66
|
+
readonly method: string
|
|
67
|
+
readonly params?: Record<string, unknown>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type JsonRpcResponse =
|
|
71
|
+
| { jsonrpc: "2.0"; id: JsonRpcId; result: unknown }
|
|
72
|
+
| { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string } }
|
|
73
|
+
|
|
74
|
+
export const rpcResult = (id: JsonRpcId, value: unknown): JsonRpcResponse => ({
|
|
75
|
+
jsonrpc: "2.0",
|
|
76
|
+
id,
|
|
77
|
+
result: value,
|
|
78
|
+
})
|
|
79
|
+
export const rpcError = (id: JsonRpcId, code: number, message: string): JsonRpcResponse => ({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
id,
|
|
82
|
+
error: { code, message },
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
export interface McpProtocolState {
|
|
86
|
+
readonly activeRequests: Map<string, AbortController>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface McpProtocolOptions {
|
|
90
|
+
readonly state?: McpProtocolState
|
|
91
|
+
readonly signal?: AbortSignal
|
|
92
|
+
readonly sendNotification?: (notification: JsonRpcNotification) => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createMcpProtocolState(): McpProtocolState {
|
|
96
|
+
return { activeRequests: new Map() }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requestKey(id: unknown): string | undefined {
|
|
100
|
+
if (typeof id === "string") return `s:${id}`
|
|
101
|
+
return typeof id === "number" && Number.isFinite(id) ? `n:${id}` : undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function progressTokenOf(params: Record<string, unknown> | undefined): ProgressToken | undefined {
|
|
105
|
+
const meta = params?._meta
|
|
106
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return undefined
|
|
107
|
+
const token = (meta as Record<string, unknown>).progressToken
|
|
108
|
+
return typeof token === "string" || (typeof token === "number" && Number.isFinite(token))
|
|
109
|
+
? token
|
|
110
|
+
: undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cancellationReason(params: Record<string, unknown> | undefined): string | undefined {
|
|
114
|
+
const reason = params?.reason
|
|
115
|
+
return typeof reason === "string" && reason.length > 0 ? reason : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function linkAbortSignal(parent: AbortSignal | undefined, child: AbortController): () => void {
|
|
119
|
+
if (parent === undefined) return () => {}
|
|
120
|
+
if (parent.aborted) {
|
|
121
|
+
child.abort(parent.reason)
|
|
122
|
+
return () => {}
|
|
123
|
+
}
|
|
124
|
+
const abort = (): void => child.abort(parent.reason)
|
|
125
|
+
parent.addEventListener("abort", abort, { once: true })
|
|
126
|
+
return () => parent.removeEventListener("abort", abort)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function abortMessage(signal: AbortSignal): string {
|
|
130
|
+
const reason = signal.reason
|
|
131
|
+
if (typeof reason === "string" && reason.length > 0) return `Cancelled: ${reason}`
|
|
132
|
+
return "Cancelled"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function compactDescription(description: string): string {
|
|
136
|
+
const text = description.replace(/\s+/g, " ").trim()
|
|
137
|
+
const sentenceEnd = text.search(/[.!?](?:\s|$)/)
|
|
138
|
+
const sentence = sentenceEnd === -1 ? text : text.slice(0, sentenceEnd + 1)
|
|
139
|
+
if (sentence.length <= 120) return sentence
|
|
140
|
+
return `${sentence.slice(0, 117).trimEnd()}...`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Dispatch one JSON-RPC message against the given tools. Returns the response, or `null` for a
|
|
145
|
+
* notification (no reply). Tool errors are reported in-band (`isError`) so the agent can react to them.
|
|
146
|
+
*/
|
|
147
|
+
export async function handleRpc(
|
|
148
|
+
message: JsonRpcRequest,
|
|
149
|
+
tools: readonly McpTool[],
|
|
150
|
+
serverInfo: { name: string; version: string },
|
|
151
|
+
features: McpServerFeatures = {},
|
|
152
|
+
options: McpProtocolOptions = {},
|
|
153
|
+
): Promise<JsonRpcResponse | null> {
|
|
154
|
+
const { id, method, params } = message
|
|
155
|
+
const isNotification = id === undefined
|
|
156
|
+
const rid = id ?? null
|
|
157
|
+
const resources = features.resources ?? []
|
|
158
|
+
const prompts = features.prompts ?? []
|
|
159
|
+
const state = options.state ?? createMcpProtocolState()
|
|
160
|
+
|
|
161
|
+
switch (method) {
|
|
162
|
+
case "initialize":
|
|
163
|
+
return rpcResult(rid, {
|
|
164
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
165
|
+
capabilities: {
|
|
166
|
+
tools: {},
|
|
167
|
+
...(resources.length > 0 ? { resources: {} } : {}),
|
|
168
|
+
...(prompts.length > 0 ? { prompts: {} } : {}),
|
|
169
|
+
},
|
|
170
|
+
serverInfo,
|
|
171
|
+
})
|
|
172
|
+
case "notifications/initialized":
|
|
173
|
+
return null
|
|
174
|
+
case "notifications/cancelled": {
|
|
175
|
+
const key = requestKey(params?.requestId)
|
|
176
|
+
if (key !== undefined) state.activeRequests.get(key)?.abort(cancellationReason(params))
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
case "ping":
|
|
180
|
+
return rpcResult(rid, {})
|
|
181
|
+
case "tools/list":
|
|
182
|
+
return rpcResult(
|
|
183
|
+
rid,
|
|
184
|
+
params?.compact === true
|
|
185
|
+
? {
|
|
186
|
+
tools: tools.map((t) => ({
|
|
187
|
+
name: t.name,
|
|
188
|
+
description: compactDescription(t.description),
|
|
189
|
+
})),
|
|
190
|
+
}
|
|
191
|
+
: {
|
|
192
|
+
tools: tools.map((t) => ({
|
|
193
|
+
name: t.name,
|
|
194
|
+
description: t.description,
|
|
195
|
+
inputSchema: t.inputSchema,
|
|
196
|
+
})),
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
case "tools/describe": {
|
|
200
|
+
const name = params?.name
|
|
201
|
+
const tool = tools.find((t) => t.name === name)
|
|
202
|
+
if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
|
|
203
|
+
return rpcResult(rid, {
|
|
204
|
+
tool: {
|
|
205
|
+
name: tool.name,
|
|
206
|
+
description: tool.description,
|
|
207
|
+
inputSchema: tool.inputSchema,
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
case "tools/call": {
|
|
212
|
+
const name = params?.name
|
|
213
|
+
const tool = tools.find((t) => t.name === name)
|
|
214
|
+
if (!tool) return rpcError(rid, -32602, `unknown tool: ${String(name)}`)
|
|
215
|
+
const args = (params?.arguments as Record<string, unknown>) ?? {}
|
|
216
|
+
const controller = new AbortController()
|
|
217
|
+
const key = requestKey(id)
|
|
218
|
+
const cleanupAbort = linkAbortSignal(options.signal, controller)
|
|
219
|
+
if (key !== undefined) state.activeRequests.set(key, controller)
|
|
220
|
+
const progressToken = progressTokenOf(params)
|
|
221
|
+
let lastProgress = Number.NEGATIVE_INFINITY
|
|
222
|
+
const reportProgress = (progress: number, total?: number): void => {
|
|
223
|
+
if (
|
|
224
|
+
progressToken === undefined ||
|
|
225
|
+
!Number.isFinite(progress) ||
|
|
226
|
+
progress <= lastProgress ||
|
|
227
|
+
(total !== undefined && !Number.isFinite(total))
|
|
228
|
+
) {
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
lastProgress = progress
|
|
232
|
+
options.sendNotification?.({
|
|
233
|
+
jsonrpc: "2.0",
|
|
234
|
+
method: "notifications/progress",
|
|
235
|
+
params: {
|
|
236
|
+
progressToken,
|
|
237
|
+
progress,
|
|
238
|
+
...(total !== undefined ? { total } : {}),
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
reportProgress(0, 1)
|
|
244
|
+
if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
|
|
245
|
+
const text = await tool.handler(args, {
|
|
246
|
+
signal: controller.signal,
|
|
247
|
+
requestId: rid,
|
|
248
|
+
...(progressToken !== undefined ? { progressToken } : {}),
|
|
249
|
+
reportProgress,
|
|
250
|
+
})
|
|
251
|
+
if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError")
|
|
252
|
+
reportProgress(1, 1)
|
|
253
|
+
return rpcResult(rid, { content: [{ type: "text", text }] })
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (controller.signal.aborted) {
|
|
256
|
+
return rpcResult(rid, {
|
|
257
|
+
content: [{ type: "text", text: abortMessage(controller.signal) }],
|
|
258
|
+
isError: true,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
262
|
+
return rpcResult(rid, { content: [{ type: "text", text: `Error: ${msg}` }], isError: true })
|
|
263
|
+
} finally {
|
|
264
|
+
cleanupAbort()
|
|
265
|
+
if (key !== undefined && state.activeRequests.get(key) === controller) {
|
|
266
|
+
state.activeRequests.delete(key)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
case "resources/list":
|
|
271
|
+
return rpcResult(rid, {
|
|
272
|
+
resources: resources.map((r) => ({
|
|
273
|
+
uri: r.uri,
|
|
274
|
+
name: r.name,
|
|
275
|
+
...(r.description !== undefined ? { description: r.description } : {}),
|
|
276
|
+
...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
|
|
277
|
+
})),
|
|
278
|
+
})
|
|
279
|
+
case "resources/read": {
|
|
280
|
+
const uri = params?.uri
|
|
281
|
+
const resource = resources.find((r) => r.uri === uri)
|
|
282
|
+
if (!resource) return rpcError(rid, -32602, `unknown resource: ${String(uri)}`)
|
|
283
|
+
try {
|
|
284
|
+
const content = await resource.read()
|
|
285
|
+
return rpcResult(rid, {
|
|
286
|
+
contents: [
|
|
287
|
+
{
|
|
288
|
+
uri: resource.uri,
|
|
289
|
+
mimeType: content.mimeType ?? resource.mimeType ?? "text/plain",
|
|
290
|
+
text: content.text,
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
296
|
+
return rpcError(rid, -32000, msg)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
case "prompts/list":
|
|
300
|
+
return rpcResult(rid, {
|
|
301
|
+
prompts: prompts.map((p) => ({
|
|
302
|
+
name: p.name,
|
|
303
|
+
description: p.description,
|
|
304
|
+
...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
|
|
305
|
+
})),
|
|
306
|
+
})
|
|
307
|
+
case "prompts/get": {
|
|
308
|
+
const name = params?.name
|
|
309
|
+
const prompt = prompts.find((p) => p.name === name)
|
|
310
|
+
if (!prompt) return rpcError(rid, -32602, `unknown prompt: ${String(name)}`)
|
|
311
|
+
const args = (params?.arguments as Record<string, unknown>) ?? {}
|
|
312
|
+
try {
|
|
313
|
+
return rpcResult(rid, {
|
|
314
|
+
description: prompt.description,
|
|
315
|
+
messages: await prompt.handler(args),
|
|
316
|
+
})
|
|
317
|
+
} catch (err) {
|
|
318
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
319
|
+
return rpcError(rid, -32000, msg)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
default:
|
|
323
|
+
if (isNotification) return null
|
|
324
|
+
return rpcError(rid, -32601, `method not found: ${String(method)}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `nifra_render` engine + its child-process entry — the page half of `nifra_run`. Where `nifra_run`
|
|
3
|
+
* runs requests through the BACKEND, this SSRs page routes through the WEB app (`createWebApp`), so an
|
|
4
|
+
* agent can verify a page renders and its loader ran after an edit. Spawned fresh per call (`bun
|
|
5
|
+
* mcp-render.ts <cwd>`) so the project's CURRENT route/loader code is loaded.
|
|
6
|
+
*
|
|
7
|
+
* No build required: a placeholder `clientEntry` is injected (the SSR HTML renders regardless — the
|
|
8
|
+
* client bundle only matters for hydration in the browser, not for "did this page render server-side").
|
|
9
|
+
* {@link renderPages} never throws — every failure (no config, a loader error, a render throw) becomes
|
|
10
|
+
* `{ error }` so the agent gets the actionable message.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { inProcessClient } from "@nifrajs/client"
|
|
14
|
+
import { type AppLike, runApp } from "@nifrajs/runner"
|
|
15
|
+
import { createWebApp, type RenderAdapter } from "@nifrajs/web"
|
|
16
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
|
+
import { loadApp, resolvePlugins } from "./load.ts"
|
|
18
|
+
|
|
19
|
+
const errString = (err: unknown): string =>
|
|
20
|
+
err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
21
|
+
|
|
22
|
+
export async function renderPages(
|
|
23
|
+
cwd: string,
|
|
24
|
+
requests: unknown,
|
|
25
|
+
): Promise<{ results: unknown } | { error: string }> {
|
|
26
|
+
if (!Array.isArray(requests)) return { error: "expected { requests: [...] }" }
|
|
27
|
+
|
|
28
|
+
let app: Awaited<ReturnType<typeof loadApp>>
|
|
29
|
+
try {
|
|
30
|
+
app = await loadApp(cwd, "dist")
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { error: errString(err) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Register the framework's SSR Bun plugins so `.vue`/`.svelte`/Solid route files compile on import
|
|
36
|
+
// (React/Preact JSX is Bun-native → none). Mirrors `nifra start`.
|
|
37
|
+
try {
|
|
38
|
+
const { plugin } = await import("bun")
|
|
39
|
+
for (const p of await resolvePlugins(app.framework.serverPlugins)) {
|
|
40
|
+
plugin(p as Parameters<typeof plugin>[0])
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { error: errString(err) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let webApp: AppLike
|
|
47
|
+
try {
|
|
48
|
+
webApp = createWebApp({
|
|
49
|
+
// The adapter is validated by loadApp; cast at the seam (same as the CLI's `asAdapter`).
|
|
50
|
+
adapter: app.framework.adapter as RenderAdapter,
|
|
51
|
+
manifest: discoverRoutes(app.routesDir),
|
|
52
|
+
clientEntry: "/_nifra-render-only.js", // placeholder; SSR HTML does not need the built client
|
|
53
|
+
...(app.backend !== undefined ? { api: inProcessClient(app.backend as never) } : {}),
|
|
54
|
+
}) as unknown as AppLike
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { error: errString(err) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return { results: await runApp(webApp, requests as Parameters<typeof runApp>[1]) }
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { error: errString(err) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Child-process entry: read `{ requests }` from stdin, SSR, print JSON. Guarded so importing this
|
|
67
|
+
// module in a test does not execute it.
|
|
68
|
+
if (import.meta.main) {
|
|
69
|
+
const cwd = process.argv[2] ?? process.cwd()
|
|
70
|
+
let output: unknown
|
|
71
|
+
try {
|
|
72
|
+
const { requests } = JSON.parse(await Bun.stdin.text()) as { requests: unknown }
|
|
73
|
+
output = await renderPages(cwd, requests)
|
|
74
|
+
} catch {
|
|
75
|
+
output = { error: "invalid input: expected JSON { requests: [...] }" }
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(JSON.stringify(output, null, 2))
|
|
78
|
+
}
|
package/src/mcp-run.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `nifra_run` engine + its child-process entry. The MCP server spawns this file fresh on every
|
|
3
|
+
* `nifra_run` call (`bun mcp-run.ts <cwd>`) so the project's CURRENT backend code is loaded — picking
|
|
4
|
+
* up the agent's latest edits. {@link runBackend} is the pure-ish core (resolve entry → import → run),
|
|
5
|
+
* exported so it's unit-testable in-process; the entry below wires it to stdin/stdout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs"
|
|
9
|
+
import { resolve } from "node:path"
|
|
10
|
+
import { pathToFileURL } from "node:url"
|
|
11
|
+
import { type AppLike, runApp } from "@nifrajs/runner"
|
|
12
|
+
|
|
13
|
+
const ENTRY_CANDIDATES = ["backend.ts", "app.ts", "src/backend.ts", "src/app.ts"]
|
|
14
|
+
|
|
15
|
+
const errString = (err: unknown): string =>
|
|
16
|
+
err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
17
|
+
|
|
18
|
+
export async function loadBackend(
|
|
19
|
+
cwd: string,
|
|
20
|
+
entry?: string,
|
|
21
|
+
): Promise<{ app: AppLike } | { error: string }> {
|
|
22
|
+
const candidates = entry && entry.length > 0 ? [entry] : ENTRY_CANDIDATES
|
|
23
|
+
const found = candidates.map((c) => resolve(cwd, c)).find((p) => existsSync(p))
|
|
24
|
+
if (!found) {
|
|
25
|
+
return { error: `no backend entry found in ${cwd} (looked for ${candidates.join(", ")})` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let mod: Record<string, unknown>
|
|
29
|
+
try {
|
|
30
|
+
mod = (await import(pathToFileURL(found).href)) as Record<string, unknown>
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { error: errString(err) }
|
|
33
|
+
}
|
|
34
|
+
const app = (mod.app ?? mod.backend ?? mod.default) as AppLike | undefined
|
|
35
|
+
if (!app || typeof app.fetch !== "function") {
|
|
36
|
+
return {
|
|
37
|
+
error: `${found} does not export a nifra app (expected \`app\`, \`backend\`, or default).`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { app }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the backend entry under `cwd`, import it, and run `requests` through its exported app. Never
|
|
45
|
+
* throws — every failure (missing/invalid entry, import/compile/runtime error) becomes `{ error }`, so
|
|
46
|
+
* the caller (and the agent) always gets actionable output. An import error here IS the failure the
|
|
47
|
+
* agent needs to see and fix.
|
|
48
|
+
*/
|
|
49
|
+
export async function runBackend(
|
|
50
|
+
cwd: string,
|
|
51
|
+
requests: unknown,
|
|
52
|
+
entry?: string,
|
|
53
|
+
): Promise<{ results: unknown } | { error: string }> {
|
|
54
|
+
if (!Array.isArray(requests)) return { error: "expected { requests: [...] }" }
|
|
55
|
+
|
|
56
|
+
const loaded = await loadBackend(cwd, entry)
|
|
57
|
+
if ("error" in loaded) return loaded
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return { results: await runApp(loaded.app, requests as Parameters<typeof runApp>[1]) }
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { error: errString(err) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface WorkerMessage {
|
|
67
|
+
readonly id?: unknown
|
|
68
|
+
readonly input?: { readonly requests?: unknown; readonly entry?: string }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function redirectConsoleToStderr(): void {
|
|
72
|
+
const write = (level: string, args: unknown[]): void => {
|
|
73
|
+
Bun.stderr.write(`[${level}] ${args.map((arg) => String(arg)).join(" ")}\n`)
|
|
74
|
+
}
|
|
75
|
+
console.debug = (...args: unknown[]) => write("debug", args)
|
|
76
|
+
console.info = (...args: unknown[]) => write("info", args)
|
|
77
|
+
console.log = (...args: unknown[]) => write("log", args)
|
|
78
|
+
console.warn = (...args: unknown[]) => write("warn", args)
|
|
79
|
+
console.error = (...args: unknown[]) => write("error", args)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runWorker(cwd: string): Promise<void> {
|
|
83
|
+
redirectConsoleToStderr()
|
|
84
|
+
const apps = new Map<string, Promise<{ app: AppLike } | { error: string }>>()
|
|
85
|
+
const decoder = new TextDecoder()
|
|
86
|
+
let buffer = ""
|
|
87
|
+
const send = (id: unknown, output: unknown): void => {
|
|
88
|
+
process.stdout.write(`${JSON.stringify({ id, output })}\n`)
|
|
89
|
+
}
|
|
90
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
91
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
|
|
92
|
+
let nl = buffer.indexOf("\n")
|
|
93
|
+
while (nl !== -1) {
|
|
94
|
+
const line = buffer.slice(0, nl).trim()
|
|
95
|
+
buffer = buffer.slice(nl + 1)
|
|
96
|
+
nl = buffer.indexOf("\n")
|
|
97
|
+
if (line === "") continue
|
|
98
|
+
|
|
99
|
+
let message: WorkerMessage
|
|
100
|
+
try {
|
|
101
|
+
message = JSON.parse(line) as WorkerMessage
|
|
102
|
+
} catch {
|
|
103
|
+
send(null, { error: "invalid worker input: expected JSON line" })
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const requests = message.input?.requests
|
|
107
|
+
const entry = message.input?.entry
|
|
108
|
+
if (!Array.isArray(requests)) {
|
|
109
|
+
send(message.id, { error: "expected { requests: [...] }" })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const key = entry ?? ""
|
|
113
|
+
let loaded = apps.get(key)
|
|
114
|
+
if (loaded === undefined) {
|
|
115
|
+
loaded = loadBackend(cwd, entry)
|
|
116
|
+
apps.set(key, loaded)
|
|
117
|
+
}
|
|
118
|
+
const app = await loaded
|
|
119
|
+
if ("error" in app) {
|
|
120
|
+
send(message.id, app)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
send(message.id, {
|
|
125
|
+
results: await runApp(app.app, requests as Parameters<typeof runApp>[1]),
|
|
126
|
+
})
|
|
127
|
+
} catch (err) {
|
|
128
|
+
send(message.id, { error: errString(err) })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Child-process entry: read `{ requests, entry? }` from stdin, run, print JSON to stdout. Guarded so
|
|
135
|
+
// this only executes when run directly (not when imported by a test).
|
|
136
|
+
if (import.meta.main) {
|
|
137
|
+
const cwd = process.argv[2] ?? process.cwd()
|
|
138
|
+
if (process.argv.includes("--worker")) {
|
|
139
|
+
await runWorker(cwd)
|
|
140
|
+
process.exit(0)
|
|
141
|
+
}
|
|
142
|
+
let output: unknown
|
|
143
|
+
try {
|
|
144
|
+
const { requests, entry } = JSON.parse(await Bun.stdin.text()) as {
|
|
145
|
+
requests: unknown
|
|
146
|
+
entry?: string
|
|
147
|
+
}
|
|
148
|
+
output = await runBackend(cwd, requests, entry)
|
|
149
|
+
} catch {
|
|
150
|
+
output = { error: "invalid input: expected JSON { requests: [...] }" }
|
|
151
|
+
}
|
|
152
|
+
process.stdout.write(JSON.stringify(output, null, 2))
|
|
153
|
+
}
|