@nifrajs/cli 1.0.0-beta.4 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.1.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,11 +41,12 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.0.0-beta.4",
45
- "@nifrajs/runner": "1.0.0-beta.4",
46
- "@nifrajs/schema": "1.0.0-beta.4",
47
- "@nifrajs/web": "1.0.0-beta.4",
48
- "create-nifra": "1.0.0-beta.4"
44
+ "@nifrajs/client": "1.1.0",
45
+ "@nifrajs/mcp": "1.1.0",
46
+ "@nifrajs/runner": "1.1.0",
47
+ "@nifrajs/schema": "1.1.0",
48
+ "@nifrajs/web": "1.1.0",
49
+ "create-nifra": "1.1.0"
49
50
  },
50
51
  "repository": {
51
52
  "type": "git",
package/src/cli.ts CHANGED
@@ -82,7 +82,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
82
82
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
83
83
 
84
84
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
85
- const CLI_VERSION = "1.0.0-beta.4"
85
+ const CLI_VERSION = "1.1.0"
86
86
 
87
87
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
88
88
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
package/src/introspect.ts CHANGED
@@ -197,6 +197,11 @@ const CONVENTIONS = `## Conventions (summary)
197
197
  - **Backend:** \`server()\` from \`@nifrajs/core\`; returning a value sends JSON. Validate untrusted input with
198
198
  a route schema (\`{ body, query, params }\` using \`t\` from \`@nifrajs/schema\` or any Standard Schema) and read
199
199
  the typed \`c.body\` / \`c.query\` / \`c.params\` — never hand-parse.
200
+ - **Cross-cutting concerns** (rate limit / \`429\`, CORS, auth, CSRF, caching, security headers, body limits)
201
+ → \`@nifrajs/middleware\`, applied with \`app.use(...)\`. Don't hand-roll them; call \`nifra_docs("middleware")\`
202
+ for the full list + usage.
203
+ - **Need the exact TypeScript of a \`@nifrajs/*\` symbol?** Call \`nifra_types({ name })\` (e.g. \`RateLimitStore\`,
204
+ \`RouteSchema\`) — the authoritative, complete declaration. Never read \`@nifrajs\` \`.d.ts\` files.
200
205
  - **Response contract (no drift):** declare \`{ response: t.object({...}) }\` on a route to lock its output
201
206
  shape — the handler is type-checked against it and the client sees exactly that shape. The contract above
202
207
  is the single source of truth for both sides.
@@ -10,20 +10,22 @@
10
10
  import { renderDocsResult } from "./docs-search.ts"
11
11
  import { type Example, renderExamplesResult } from "./examples.ts"
12
12
  import type { McpTool } from "./mcp-protocol.ts"
13
+ import { renderTypesResult, type TypeEntry } from "./types-search.ts"
13
14
 
14
15
  const clamp = (n: number | undefined, lo: number, hi: number, dflt: number): number =>
15
16
  Math.min(Math.max(typeof n === "number" ? n : dflt, lo), hi)
16
17
 
17
- /** Build `nifra_docs` + `nifra_example` over injected corpus loaders. */
18
+ /** Build `nifra_docs` + `nifra_example` + `nifra_types` over injected corpus loaders. */
18
19
  export function docsTools(
19
20
  loadDocs: () => Promise<string | undefined>,
20
21
  loadExamples: () => Promise<Example[] | undefined>,
22
+ loadTypes: () => Promise<TypeEntry[] | undefined>,
21
23
  ): McpTool[] {
22
24
  return [
23
25
  {
24
26
  name: "nifra_docs",
25
27
  description:
26
- 'Search nifra\'s framework documentation and get back ONLY the matching sections — auth, uploads, ISR, WebSockets, loaders, deployment, anything. Call with no query for the cheap section index; pass query (e.g. "isr revalidate") for the top matching sections. Use this instead of reading llms-full.txt (~150 KB) whole.',
28
+ "Search nifra's framework documentation and get back ONLY the matching sections — auth, uploads, ISR, WebSockets, loaders, deployment, anything. Call with no query for the cheap section index; pass query (e.g. \"isr revalidate\") for the top matching sections. Use this instead of reading llms-full.txt (~150 KB) whole. For the EXACT TypeScript of a type/interface/function, call nifra_types instead (don't read .d.ts files).",
27
29
  inputSchema: {
28
30
  type: "object",
29
31
  properties: {
@@ -68,5 +70,38 @@ export function docsTools(
68
70
  return renderExamplesResult(corpus, query, clamp(limit, 1, 5, 3))
69
71
  },
70
72
  },
73
+ {
74
+ name: "nifra_types",
75
+ description:
76
+ 'Get the EXACT TypeScript declaration of any exported @nifrajs/* symbol — interface, type, class, function, const. Each signature is generated from the package\'s built .d.ts, so it is the LITERAL declaration: complete, authoritative, never prose and never truncated. THIS IS THE SOURCE OF TRUTH for nifra\'s types — do NOT read node_modules/@nifrajs/**/*.d.ts; call this instead. Pass `name` for an exact symbol (e.g. "RateLimitStore", "RouteSchema", "Context", "rateLimit"); pass `query` to search by keyword; omit both for the per-package index of names.',
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ name: {
81
+ type: "string",
82
+ description:
83
+ "Exact symbol name — returns its literal declaration (e.g. RateLimitStore).",
84
+ },
85
+ query: {
86
+ type: "string",
87
+ description:
88
+ "Keyword search over names + signatures (when you don't know the exact name).",
89
+ },
90
+ limit: {
91
+ type: "number",
92
+ description: "Max results for a query/search (default 5, max 8).",
93
+ },
94
+ },
95
+ additionalProperties: false,
96
+ },
97
+ handler: async (args) => {
98
+ const corpus = await loadTypes()
99
+ if (corpus === undefined) {
100
+ return "types corpus missing — run `bun run build && bun run gen:llms` in the nifra repo (published builds ship it)."
101
+ }
102
+ const { name, query, limit } = args as { name?: string; query?: string; limit?: number }
103
+ return renderTypesResult(corpus, name, query, clamp(limit, 1, 8, 5))
104
+ },
105
+ },
71
106
  ]
72
107
  }
package/src/mcp-http.ts CHANGED
@@ -4,139 +4,49 @@
4
4
  * Both tools read the bundled corpus (llms-full.txt / examples.json shipped in this package) and ignore
5
5
  * `cwd`, so they reuse the exact definitions from {@link projectTools} — one source, no drift.
6
6
  *
7
- * {@link handleMcpHttp} is a plain Web `fetch` handler, so it self-hosts ANYWHERE Bun runs — the
8
- * simplest path is `nifra docs-mcp` on a VPS behind a reverse proxy (Caddy/nginx + TLS), where the
9
- * bundled corpus reads straight from disk (`Bun.file`). `export default { port, fetch }` also lets
10
- * `bun run mcp-http.ts` serve it directly, and the same handler runs on Cloudflare/Vercel edge — only
11
- * there must you inline the two corpus files as string imports (a Worker has no filesystem). The
12
- * dispatch is the shared, transport-agnostic {@link handleRpc}.
7
+ * The transport itself (body cap, CORS, JSON-RPC dispatch) lives in `@nifrajs/mcp/http`; this module is
8
+ * the docs-specific layer over it: it supplies the bundled corpus tools and the `nifra-docs` server info.
9
+ * {@link handleMcpHttp} self-hosts ANYWHERE Bun runs — the simplest path is `nifra docs-mcp` on a VPS; the
10
+ * same handler runs on Cloudflare/Vercel edge (there inline the corpus as string imports — a Worker has no
11
+ * filesystem). `export default { port, fetch }` also lets `bun run mcp-http.ts` serve it directly.
13
12
  */
14
13
 
14
+ import { type McpHttpOptions, respondMcpHttp as respondMcpHttpCore } from "@nifrajs/mcp/http"
15
+ import type { McpTool } from "@nifrajs/mcp/protocol"
15
16
  import { loadDocsCorpus } from "./docs-search.ts"
16
17
  import { loadExamplesCorpus } from "./examples.ts"
17
18
  import { docsTools } from "./mcp-docs-tools.ts"
18
- import { handleRpc, type JsonRpcRequest, type McpTool, rpcError } from "./mcp-protocol.ts"
19
+ import { loadTypesCorpus } from "./types-search.ts"
19
20
 
21
+ export type { McpHttpOptions } from "@nifrajs/mcp/http"
20
22
  export type { Example } from "./examples.ts"
21
23
  // Re-exported so a self-host (e.g. a Cloudflare-Pages `/mcp` worker route) gets the corpus-injectable
22
24
  // tool factory + the transport core from one entry: `import { respondMcpHttp, docsTools } from "@nifrajs/cli/mcp"`.
23
25
  export { docsTools } from "./mcp-docs-tools.ts"
26
+ export type { TypeEntry } from "./types-search.ts"
24
27
 
25
28
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
26
- const VERSION = "1.0.0-beta.4"
29
+ const VERSION = "1.1.0"
27
30
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
28
-
29
- const CORS: Record<string, string> = {
30
- "access-control-allow-origin": "*",
31
- "access-control-allow-methods": "POST, GET, OPTIONS",
32
- "access-control-allow-headers": "content-type",
33
- }
34
-
35
- const DEFAULT_MAX_BODY_BYTES = 1_000_000
36
- const TEXT_DECODER = new TextDecoder()
37
-
38
- export interface McpHttpOptions {
39
- /** Maximum JSON-RPC request body size in bytes. Default 1 MB. */
40
- readonly maxBodyBytes?: number
41
- }
42
-
43
- function parseContentLength(value: string): number | undefined {
44
- if (value.length === 0) return undefined
45
- let length = 0
46
- for (let i = 0; i < value.length; i++) {
47
- const digit = value.charCodeAt(i) - 48
48
- if (digit < 0 || digit > 9) return undefined
49
- length = length * 10 + digit
50
- if (length > Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY
51
- }
52
- return length
53
- }
54
-
55
- async function readJsonBounded(
56
- request: Request,
57
- maxBytes: number,
58
- ): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> {
59
- const declared = request.headers.get("content-length")
60
- if (declared !== null) {
61
- const length = parseContentLength(declared)
62
- if (length === undefined) return { ok: false, status: 400 }
63
- if (length > maxBytes) return { ok: false, status: 413 }
64
- }
65
-
66
- const body = request.body
67
- if (body === null) return { ok: false, status: 400 }
68
-
69
- const reader = body.getReader()
70
- const chunks: Uint8Array[] = []
71
- let total = 0
72
- for (;;) {
73
- const { done, value } = await reader.read()
74
- if (done) break
75
- total += value.byteLength
76
- if (total > maxBytes) {
77
- await reader.cancel()
78
- return { ok: false, status: 413 }
79
- }
80
- chunks.push(value)
81
- }
82
-
83
- const bytes = new Uint8Array(total)
84
- let offset = 0
85
- for (const chunk of chunks) {
86
- bytes.set(chunk, offset)
87
- offset += chunk.byteLength
88
- }
89
- try {
90
- return { ok: true, value: JSON.parse(TEXT_DECODER.decode(bytes)) as unknown }
91
- } catch {
92
- return { ok: false, status: 400 }
93
- }
94
- }
31
+ const DOCS_HEALTH =
32
+ "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
95
33
 
96
34
  /** The two project-independent tools, reading the package's bundled corpus from disk (CLI use). */
97
35
  export function publicDocsTools(): McpTool[] {
98
- return docsTools(loadDocsCorpus, loadExamplesCorpus)
36
+ return docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus)
99
37
  }
100
38
 
101
39
  /**
102
- * The transport core, shared by every HTTP host (CLI `docs-mcp`, the site's edge worker): handle one
103
- * MCP request against the given `tools`. POST a JSON-RPC body → JSON-RPC response; GET is a health page;
104
- * OPTIONS is the CORS preflight. Never throws — a bad body becomes a JSON-RPC parse error. Pass the
105
- * tools so each host supplies its own corpus source (disk vs cached fetch) behind identical defs.
40
+ * Handle one MCP request against the given `tools` with the docs server identity. A thin docs-flavored
41
+ * wrapper over the shared {@link respondMcpHttpCore} so the `@nifrajs/cli/mcp` self-host surface keeps its
42
+ * `(request, tools, options?)` shape (the site's edge worker calls it with two args).
106
43
  */
107
- export async function respondMcpHttp(
44
+ export function respondMcpHttp(
108
45
  request: Request,
109
46
  tools: McpTool[],
110
47
  options: McpHttpOptions = {},
111
48
  ): Promise<Response> {
112
- if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
113
- if (request.method === "GET") {
114
- return new Response(
115
- "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example.",
116
- { headers: { "content-type": "text/plain; charset=utf-8", ...CORS } },
117
- )
118
- }
119
- if (request.method !== "POST") {
120
- return new Response("method not allowed", {
121
- status: 405,
122
- headers: { allow: "POST, GET", ...CORS },
123
- })
124
- }
125
- const parsed = await readJsonBounded(request, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES)
126
- if (!parsed.ok) {
127
- if (parsed.status === 413) {
128
- return Response.json(rpcError(null, -32000, "payload too large"), {
129
- status: 413,
130
- headers: CORS,
131
- })
132
- }
133
- return Response.json(rpcError(null, -32700, "parse error"), { status: 400, headers: CORS })
134
- }
135
- const message = parsed.value as JsonRpcRequest
136
- const response = await handleRpc(message, tools, SERVER_INFO, {}, { signal: request.signal })
137
- // A notification (no id) yields null — acknowledge with 204, no body.
138
- if (response === null) return new Response(null, { status: 204, headers: CORS })
139
- return Response.json(response, { headers: CORS })
49
+ return respondMcpHttpCore(request, tools, SERVER_INFO, { health: DOCS_HEALTH, ...options })
140
50
  }
141
51
 
142
52
  /** The CLI HTTP handler: serves the disk-backed corpus tools. (`nifra docs-mcp` / `bun run` this file.) */
@@ -1,326 +1,7 @@
1
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.
2
+ * The MCP JSON-RPC dispatch now lives in `@nifrajs/mcp` (shared with nifra apps that expose their own
3
+ * MCP server / MCP Apps widgets). This module is a thin re-export so the CLI's existing imports — and
4
+ * the `@nifrajs/cli/mcp` self-host surface — keep resolving here unchanged.
5
5
  */
6
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
- }
7
+ export * from "@nifrajs/mcp/protocol"
package/src/mcp-run.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs"
9
- import { resolve } from "node:path"
9
+ import { resolve, sep } from "node:path"
10
10
  import { pathToFileURL } from "node:url"
11
11
  import { type AppLike, runApp } from "@nifrajs/runner"
12
12
 
@@ -24,6 +24,12 @@ export async function loadBackend(
24
24
  if (!found) {
25
25
  return { error: `no backend entry found in ${cwd} (looked for ${candidates.join(", ")})` }
26
26
  }
27
+ // The `entry` arg comes from MCP args; importing it executes it. Keep it inside the project root so a
28
+ // crafted `entry` (`../../x`, an absolute path) can't run arbitrary modules outside the project.
29
+ const root = resolve(cwd)
30
+ if (found !== root && !found.startsWith(root + sep)) {
31
+ return { error: `refusing to load a backend entry outside the project root: ${entry}` }
32
+ }
27
33
 
28
34
  let mod: Record<string, unknown>
29
35
  try {