@nifrajs/cli 1.3.1 → 1.5.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.d.ts.map +1 -1
- package/dist/cli.js +42 -1
- package/dist/cli.js.map +1 -1
- package/dist/diff-tool.d.ts +35 -0
- package/dist/diff-tool.d.ts.map +1 -0
- package/dist/diff-tool.js +107 -0
- package/dist/diff-tool.js.map +1 -0
- package/dist/introspect.d.ts +5 -12
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +8 -23
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +3 -16
- package/dist/mcp.js.map +1 -1
- package/docs/llms-full.txt +55 -11
- package/docs/types.json +569 -22
- package/package.json +8 -7
- package/src/cli.ts +40 -1
- package/src/diff-tool.ts +130 -0
- package/src/introspect.ts +15 -37
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +7 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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,12 +41,13 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@nifrajs/client": "1.
|
|
45
|
-
"@nifrajs/
|
|
46
|
-
"@nifrajs/
|
|
47
|
-
"@nifrajs/
|
|
48
|
-
"@nifrajs/
|
|
49
|
-
"
|
|
44
|
+
"@nifrajs/client": "1.5.0",
|
|
45
|
+
"@nifrajs/core": "1.5.0",
|
|
46
|
+
"@nifrajs/mcp": "1.5.0",
|
|
47
|
+
"@nifrajs/runner": "1.5.0",
|
|
48
|
+
"@nifrajs/schema": "1.5.0",
|
|
49
|
+
"@nifrajs/web": "1.5.0",
|
|
50
|
+
"create-nifra": "1.5.0"
|
|
50
51
|
},
|
|
51
52
|
"repository": {
|
|
52
53
|
"type": "git",
|
package/src/cli.ts
CHANGED
|
@@ -65,6 +65,13 @@ Usage:
|
|
|
65
65
|
nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
|
|
66
66
|
server-only imports in routes/). Run as "done"; --json for agents;
|
|
67
67
|
--lints-only skips tsc for a near-instant inner-loop pass.
|
|
68
|
+
nifra snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
|
|
69
|
+
JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
|
|
70
|
+
nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
|
|
71
|
+
against the committed baseline. Direction-aware (a new required
|
|
72
|
+
request field or a removed response field breaks; widening a
|
|
73
|
+
request enum or adding a response field doesn't) and fails
|
|
74
|
+
closed. Exits non-zero on any breaking change — run it in CI.
|
|
68
75
|
nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
|
|
69
76
|
(resolve at Bun runtime, break tsc + standalone install);
|
|
70
77
|
--auto-fix writes safe local-version dependency fixes.
|
|
@@ -82,7 +89,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
82
89
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
83
90
|
|
|
84
91
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
85
|
-
const CLI_VERSION = "1.
|
|
92
|
+
const CLI_VERSION = "1.5.0"
|
|
86
93
|
|
|
87
94
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
88
95
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -332,6 +339,38 @@ async function main(): Promise<void> {
|
|
|
332
339
|
process.exitCode = 1
|
|
333
340
|
return
|
|
334
341
|
}
|
|
342
|
+
// `snapshot` / `diff` load ONLY backend.ts (the API contract) — like `check`, they must run on an
|
|
343
|
+
// API-only project, so they dispatch before the eager `loadApp`.
|
|
344
|
+
if (command === "snapshot") {
|
|
345
|
+
const { runSnapshot } = await import("./diff-tool.ts")
|
|
346
|
+
const outIdx = argv.indexOf("--out")
|
|
347
|
+
const out = outIdx !== -1 ? argv[outIdx + 1] : undefined
|
|
348
|
+
if (outIdx !== -1 && (out === undefined || out.startsWith("-"))) {
|
|
349
|
+
console.error("[nifra] --out needs a file path")
|
|
350
|
+
process.exitCode = 1
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
357
|
+
process.exitCode = 1
|
|
358
|
+
}
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
if (command === "diff") {
|
|
362
|
+
const { runDiff, DEFAULT_SNAPSHOT_FILE } = await import("./diff-tool.ts")
|
|
363
|
+
const baseline = argv.slice(1).find((arg) => !arg.startsWith("-")) ?? DEFAULT_SNAPSHOT_FILE
|
|
364
|
+
try {
|
|
365
|
+
if (!(await runDiff(process.cwd(), baseline, { json: argv.includes("--json") }))) {
|
|
366
|
+
process.exitCode = 1
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
370
|
+
process.exitCode = 1
|
|
371
|
+
}
|
|
372
|
+
return
|
|
373
|
+
}
|
|
335
374
|
// `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
|
|
336
375
|
// `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
|
|
337
376
|
if (command === "port") {
|
package/src/diff-tool.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra snapshot` / `nifra diff` — the API breaking-change gate.
|
|
3
|
+
*
|
|
4
|
+
* nifra snapshot [--out api-snapshot.json] Write the backend's route contract as plain JSON.
|
|
5
|
+
* nifra diff <baseline.json> [--json] Re-snapshot and compare against the committed
|
|
6
|
+
* baseline; exit non-zero on any breaking change.
|
|
7
|
+
*
|
|
8
|
+
* The CI loop: commit a snapshot on main, run `nifra diff api-snapshot.json` on every PR, and an
|
|
9
|
+
* accidental contract break (removed route, new required field, retyped response) fails the build
|
|
10
|
+
* before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
|
|
11
|
+
* this module only loads `backend.ts` and renders the result.
|
|
12
|
+
*
|
|
13
|
+
* Loads ONLY `backend.ts` — the API contract — so it works on an API-only project with no
|
|
14
|
+
* framework config or routes/ directory (unlike the eager `loadApp`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from "node:fs"
|
|
18
|
+
import { resolve } from "node:path"
|
|
19
|
+
import {
|
|
20
|
+
diffRouteSnapshots,
|
|
21
|
+
type RouteChange,
|
|
22
|
+
type RouteSnapshot,
|
|
23
|
+
type RoutesDiff,
|
|
24
|
+
snapshotRoutes,
|
|
25
|
+
} from "@nifrajs/core/diff"
|
|
26
|
+
|
|
27
|
+
/** Snapshot file envelope — versioned so a future format change can migrate instead of misparse. */
|
|
28
|
+
export interface SnapshotFile {
|
|
29
|
+
readonly nifraSnapshot: 1
|
|
30
|
+
readonly routes: readonly RouteSnapshot[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_SNAPSHOT_FILE = "api-snapshot.json"
|
|
34
|
+
|
|
35
|
+
/** Parse + validate a snapshot file's content. Throws with an actionable message on any mismatch. */
|
|
36
|
+
export function parseSnapshotFile(content: string, sourcePath: string): SnapshotFile {
|
|
37
|
+
let parsed: unknown
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(content)
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`[nifra] ${sourcePath} is not valid JSON — regenerate it with \`nifra snapshot\`.`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const record = parsed as Partial<SnapshotFile> | null
|
|
46
|
+
if (
|
|
47
|
+
record === null ||
|
|
48
|
+
typeof record !== "object" ||
|
|
49
|
+
record.nifraSnapshot !== 1 ||
|
|
50
|
+
!Array.isArray(record.routes)
|
|
51
|
+
) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[nifra] ${sourcePath} is not a nifra API snapshot — expected { "nifraSnapshot": 1, "routes": [...] }.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return record as SnapshotFile
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SEVERITY_MARK = {
|
|
60
|
+
breaking: "✖ breaking",
|
|
61
|
+
compatible: "✓ compatible",
|
|
62
|
+
info: "ℹ info",
|
|
63
|
+
} as const
|
|
64
|
+
|
|
65
|
+
const describeChange = (change: RouteChange): string => {
|
|
66
|
+
const where = change.section === "route" ? "" : ` ${change.section}`
|
|
67
|
+
return `${SEVERITY_MARK[change.severity]} ${change.method} ${change.path}${where}: ${change.message}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render a diff for terminals: breaking first, then info, then compatible. */
|
|
71
|
+
export function formatDiff(diff: RoutesDiff): string {
|
|
72
|
+
if (diff.changes.length === 0) return "No API contract changes."
|
|
73
|
+
const order: Record<RouteChange["severity"], number> = { breaking: 0, info: 1, compatible: 2 }
|
|
74
|
+
const lines = [...diff.changes]
|
|
75
|
+
.sort((a, b) => order[a.severity] - order[b.severity])
|
|
76
|
+
.map(describeChange)
|
|
77
|
+
const breaking = diff.changes.filter((change) => change.severity === "breaking").length
|
|
78
|
+
const summary = diff.hasBreaking
|
|
79
|
+
? `${breaking} breaking change${breaking === 1 ? "" : "s"} — existing clients will fail.`
|
|
80
|
+
: "No breaking changes."
|
|
81
|
+
return `${lines.join("\n")}\n\n${summary}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Import `backend.ts` from `cwd` and snapshot its routes. */
|
|
85
|
+
async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
86
|
+
const backendPath = resolve(cwd, "backend.ts")
|
|
87
|
+
if (!existsSync(backendPath)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[nifra] no backend.ts in ${cwd} — \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
const backend = ((await import(backendPath)) as { backend?: unknown }).backend
|
|
93
|
+
if (backend === undefined) {
|
|
94
|
+
throw new Error(`[nifra] ${backendPath} does not export \`backend\`.`)
|
|
95
|
+
}
|
|
96
|
+
return snapshotRoutes(backend)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `nifra snapshot`: write the current contract as the CI baseline. */
|
|
100
|
+
export async function runSnapshot(cwd: string, options: { out?: string }): Promise<void> {
|
|
101
|
+
const routes = await snapshotBackend(cwd)
|
|
102
|
+
const file: SnapshotFile = { nifraSnapshot: 1, routes }
|
|
103
|
+
const outPath = resolve(cwd, options.out ?? DEFAULT_SNAPSHOT_FILE)
|
|
104
|
+
await Bun.write(outPath, `${JSON.stringify(file, null, 2)}\n`)
|
|
105
|
+
console.log(`[nifra] wrote ${routes.length} route${routes.length === 1 ? "" : "s"} to ${outPath}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `nifra diff`: compare the current contract against a baseline. Returns false on breaking changes. */
|
|
109
|
+
export async function runDiff(
|
|
110
|
+
cwd: string,
|
|
111
|
+
baselinePath: string,
|
|
112
|
+
options: { json?: boolean },
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
const resolved = resolve(cwd, baselinePath)
|
|
115
|
+
const baselineFile = Bun.file(resolved)
|
|
116
|
+
if (!(await baselineFile.exists())) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[nifra] baseline not found: ${resolved} — create it on your main branch with \`nifra snapshot\`.`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
|
|
122
|
+
const current = await snapshotBackend(cwd)
|
|
123
|
+
const diff = diffRouteSnapshots(baseline.routes, current)
|
|
124
|
+
if (options.json === true) {
|
|
125
|
+
console.log(JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2))
|
|
126
|
+
} else {
|
|
127
|
+
console.log(formatDiff(diff))
|
|
128
|
+
}
|
|
129
|
+
return !diff.hasBreaking
|
|
130
|
+
}
|
package/src/introspect.ts
CHANGED
|
@@ -5,38 +5,14 @@
|
|
|
5
5
|
* *this* project's actual surface, not just generic docs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifrajs/core/reflection"
|
|
8
9
|
import type { Manifest } from "@nifrajs/web"
|
|
9
10
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
10
11
|
import type { LoadedApp } from "./load.ts"
|
|
11
12
|
|
|
12
|
-
/** Structural view of a `@nifrajs/core` route descriptor — declared locally so the CLI needs no type
|
|
13
|
-
* dependency on core. `app.routes()` returns these. */
|
|
14
|
-
interface RouteDesc {
|
|
15
|
-
readonly method: string
|
|
16
|
-
readonly path: string
|
|
17
|
-
readonly schema?: unknown
|
|
18
|
-
}
|
|
19
|
-
|
|
20
13
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
21
|
-
export function backendRoutes(backend: unknown):
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
return (backend as { routes(): RouteDesc[] }).routes()
|
|
25
|
-
} catch {
|
|
26
|
-
return []
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return []
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The JSON Schema a `t`/Standard Schema carries (nifra's `t` is TypeBox-backed and exposes `.jsonSchema`),
|
|
33
|
-
* or `undefined` for a validator that doesn't expose one. This is the exact field-level contract. */
|
|
34
|
-
function jsonSchemaOf(schema: unknown): unknown {
|
|
35
|
-
if (schema && typeof schema === "object" && "jsonSchema" in schema) {
|
|
36
|
-
const js = (schema as { jsonSchema?: unknown }).jsonSchema
|
|
37
|
-
if (js !== undefined && js !== null) return js
|
|
38
|
-
}
|
|
39
|
-
return undefined
|
|
14
|
+
export function backendRoutes(backend: unknown): ReflectedRoute[] {
|
|
15
|
+
return [...reflectRoutes(backend)]
|
|
40
16
|
}
|
|
41
17
|
|
|
42
18
|
interface JsonSchemaNode {
|
|
@@ -99,16 +75,16 @@ export function tsTypeOf(schema: unknown, depth = 0): string {
|
|
|
99
75
|
|
|
100
76
|
/** One route's request/response field shapes, indented under its line. Reads the declared schemas off the
|
|
101
77
|
* descriptor so an agent gets the exact input + output contract — not just "this route is validated". */
|
|
102
|
-
function schemaLines(schema:
|
|
103
|
-
const s = schema
|
|
78
|
+
function schemaLines(schema: ReflectedRoute["schema"]): string[] {
|
|
79
|
+
const s = schema
|
|
104
80
|
if (!s) return []
|
|
105
81
|
const out: string[] = []
|
|
106
82
|
for (const key of ["query", "body", "response"] as const) {
|
|
107
83
|
const declared = s[key]
|
|
108
84
|
if (!declared) continue
|
|
109
|
-
const js =
|
|
85
|
+
const js = declared.jsonSchema
|
|
110
86
|
out.push(
|
|
111
|
-
js
|
|
87
|
+
js !== undefined
|
|
112
88
|
? ` - ${key}: \`${tsTypeOf(js)}\``
|
|
113
89
|
: ` - ${key}: _(validated; shape not introspectable from this validator)_`,
|
|
114
90
|
)
|
|
@@ -153,7 +129,7 @@ export function clientCall(method: string, path: string, schema: unknown): strin
|
|
|
153
129
|
}
|
|
154
130
|
|
|
155
131
|
/** Markdown section listing the backend's API routes with their request + response field shapes. */
|
|
156
|
-
export function apiRoutesSection(routes: readonly
|
|
132
|
+
export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
|
|
157
133
|
if (routes.length === 0) {
|
|
158
134
|
return "## API routes\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
|
|
159
135
|
}
|
|
@@ -171,7 +147,7 @@ export function apiRoutesSection(routes: readonly RouteDesc[]): string {
|
|
|
171
147
|
* body/query/response shapes or the per-route `call` line. Bounds the first-call payload on a big backend:
|
|
172
148
|
* an agent gets the full route list (so it knows what's mounted) plus a pointer to fetch any route's full
|
|
173
149
|
* contract via the `path`/`kind` slice, instead of every schema up front. */
|
|
174
|
-
export function apiRoutesIndexSection(routes: readonly
|
|
150
|
+
export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string {
|
|
175
151
|
if (routes.length === 0) {
|
|
176
152
|
return "## API routes (backend.ts)\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
|
|
177
153
|
}
|
|
@@ -287,8 +263,10 @@ export interface RouteJson {
|
|
|
287
263
|
export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
|
|
288
264
|
const shape = (v: unknown): string | undefined => {
|
|
289
265
|
if (!v) return undefined
|
|
290
|
-
const js =
|
|
291
|
-
return js
|
|
266
|
+
const js = (v as SchemaReflection).jsonSchema
|
|
267
|
+
return js !== undefined
|
|
268
|
+
? tsTypeOf(js)
|
|
269
|
+
: "(validated; shape not introspectable from this validator)"
|
|
292
270
|
}
|
|
293
271
|
let routes = backendRoutes(app.backend)
|
|
294
272
|
if (pathPrefix !== undefined && pathPrefix !== "") {
|
|
@@ -297,7 +275,7 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
|
|
|
297
275
|
return [...routes]
|
|
298
276
|
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
|
|
299
277
|
.map((r) => {
|
|
300
|
-
const s = r.schema
|
|
278
|
+
const s = r.schema
|
|
301
279
|
const body = shape(s?.body)
|
|
302
280
|
const query = shape(s?.query)
|
|
303
281
|
const response = shape(s?.response)
|
|
@@ -353,7 +331,7 @@ export interface PageRouteInput {
|
|
|
353
331
|
|
|
354
332
|
export interface RouteTableInput {
|
|
355
333
|
readonly pages: readonly PageRouteInput[]
|
|
356
|
-
readonly api:
|
|
334
|
+
readonly api: ReadonlyArray<Pick<ReflectedRoute, "method" | "path">>
|
|
357
335
|
/** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
|
|
358
336
|
* disables the auto-mount, so no API route is marked auto-mounted. */
|
|
359
337
|
readonly apiPrefix?: string
|
package/src/mcp-http.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
26
26
|
export type { TypeEntry } from "./types-search.ts"
|
|
27
27
|
|
|
28
28
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
29
|
-
const VERSION = "1.
|
|
29
|
+
const VERSION = "1.5.0"
|
|
30
30
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
31
31
|
const DOCS_HEALTH =
|
|
32
32
|
"nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
|
package/src/mcp.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { readFile, stat } from "node:fs/promises"
|
|
27
27
|
import { basename, resolve, sep } from "node:path"
|
|
28
28
|
import { fileURLToPath } from "node:url"
|
|
29
|
+
import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
29
30
|
import { Glob } from "bun"
|
|
30
31
|
import { loadDocsCorpus } from "./docs-search.ts"
|
|
31
32
|
import { loadExamplesCorpus } from "./examples.ts"
|
|
@@ -978,17 +979,8 @@ export function projectTools(
|
|
|
978
979
|
]
|
|
979
980
|
}
|
|
980
981
|
|
|
981
|
-
/** A `.tool()`-registered route as seen through the backend's `routes()` introspection. */
|
|
982
|
-
type ToolRoute = {
|
|
983
|
-
readonly schema?: { readonly body?: unknown; readonly response?: unknown }
|
|
984
|
-
readonly tool?: {
|
|
985
|
-
readonly name: string
|
|
986
|
-
readonly description: string
|
|
987
|
-
readonly annotations?: McpTool["annotations"]
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
982
|
type ToolBackend = {
|
|
991
|
-
readonly routes?: () =>
|
|
983
|
+
readonly routes?: () => readonly unknown[]
|
|
992
984
|
readonly fetch: (req: Request) => Promise<Response>
|
|
993
985
|
}
|
|
994
986
|
|
|
@@ -997,28 +989,15 @@ type ToolBackend = {
|
|
|
997
989
|
export function extractBackendTools(backend: unknown): McpTool[] {
|
|
998
990
|
const b = backend as ToolBackend | null
|
|
999
991
|
if (!b || typeof b.routes !== "function") return []
|
|
1000
|
-
|
|
1001
|
-
try {
|
|
1002
|
-
routes = b.routes()
|
|
1003
|
-
} catch {
|
|
1004
|
-
return []
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const jsonSchemaOf = (schema: unknown): unknown => {
|
|
1008
|
-
if (schema && typeof schema === "object" && "jsonSchema" in schema) {
|
|
1009
|
-
const js = (schema as { jsonSchema?: unknown }).jsonSchema
|
|
1010
|
-
if (js !== undefined && js !== null) return js
|
|
1011
|
-
}
|
|
1012
|
-
return undefined
|
|
1013
|
-
}
|
|
992
|
+
const routes = reflectRoutes(b)
|
|
1014
993
|
|
|
1015
994
|
return routes
|
|
1016
995
|
.filter(
|
|
1017
996
|
(
|
|
1018
997
|
r,
|
|
1019
|
-
): r is
|
|
1020
|
-
schema: NonNullable<
|
|
1021
|
-
tool: NonNullable<
|
|
998
|
+
): r is ReflectedRoute & {
|
|
999
|
+
schema: NonNullable<ReflectedRoute["schema"]>
|
|
1000
|
+
tool: NonNullable<ReflectedRoute["tool"]>
|
|
1022
1001
|
} => r.schema !== undefined && r.tool !== undefined,
|
|
1023
1002
|
)
|
|
1024
1003
|
.map((r) => {
|
|
@@ -1027,7 +1006,7 @@ export function extractBackendTools(backend: unknown): McpTool[] {
|
|
|
1027
1006
|
return {
|
|
1028
1007
|
name: toolInfo.name,
|
|
1029
1008
|
description: toolInfo.description,
|
|
1030
|
-
inputSchema: (
|
|
1009
|
+
inputSchema: (s.body?.jsonSchema ?? {
|
|
1031
1010
|
type: "object",
|
|
1032
1011
|
properties: {},
|
|
1033
1012
|
}) as McpTool["inputSchema"],
|