@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/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +5 -0
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts +3 -2
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +34 -3
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +12 -15
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +15 -101
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-protocol.d.ts +4 -94
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +4 -245
- package/dist/mcp-protocol.js.map +1 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +7 -1
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +22 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +10 -5
- package/dist/mcp.js.map +1 -1
- package/dist/types-search.d.ts +37 -0
- package/dist/types-search.d.ts.map +1 -0
- package/dist/types-search.js +90 -0
- package/dist/types-search.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +312 -18
- package/docs/types.json +6845 -0
- package/package.json +7 -6
- package/src/cli.ts +1 -1
- package/src/introspect.ts +5 -0
- package/src/mcp-docs-tools.ts +37 -2
- package/src/mcp-http.ts +19 -109
- package/src/mcp-protocol.ts +4 -323
- package/src/mcp-run.ts +7 -1
- package/src/mcp.ts +10 -5
- package/src/types-search.ts +113 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
45
|
-
"@nifrajs/
|
|
46
|
-
"@nifrajs/
|
|
47
|
-
"@nifrajs/
|
|
48
|
-
"
|
|
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.
|
|
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.
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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.
|
|
29
|
+
const VERSION = "1.1.0"
|
|
27
30
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
44
|
+
export function respondMcpHttp(
|
|
108
45
|
request: Request,
|
|
109
46
|
tools: McpTool[],
|
|
110
47
|
options: McpHttpOptions = {},
|
|
111
48
|
): Promise<Response> {
|
|
112
|
-
|
|
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.) */
|
package/src/mcp-protocol.ts
CHANGED
|
@@ -1,326 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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 {
|