@nifrajs/cli 5.0.0 → 5.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/README.md +7 -2
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +4 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +24 -7
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +2 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +8 -2
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +43 -10
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +39 -33
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -48
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +5 -3
- package/dist/client-module.d.ts.map +1 -1
- package/dist/client-module.js +34 -10
- package/dist/client-module.js.map +1 -1
- package/dist/command-catalog.d.ts +9 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +67 -22
- package/dist/command-catalog.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +93 -12
- package/dist/fix-recipes.js.map +1 -1
- package/dist/init-agents.d.ts +16 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +3 -2
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +14 -1
- package/dist/internal/project-specifiers.d.ts.map +1 -1
- package/dist/internal/project-specifiers.js +54 -15
- package/dist/internal/project-specifiers.js.map +1 -1
- package/dist/internal/source-facts.d.ts +6 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +9 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +32 -1
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +5 -8
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +2 -2
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +23 -3
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +6 -21
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +8 -3
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +13 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +23 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +40 -17
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +40 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1316 -228
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +34 -0
- package/dist/output-schemas.d.ts.map +1 -1
- package/dist/output-schemas.js +132 -21
- package/dist/output-schemas.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +5 -0
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +3 -1
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +3 -1
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +6 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +37 -0
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +20 -1
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +29 -3
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +236 -22
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/security.d.ts +3 -2
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +42 -11
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +31 -9
- package/dist/rules/zones.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +127 -17
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +3 -9
- package/dist/test-tool.js.map +1 -1
- package/dist/upgrade.d.ts +20 -5
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +86 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +5 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts.map +1 -1
- package/dist/version-drift.js +55 -3
- package/dist/version-drift.js.map +1 -1
- package/docs/examples.json +2 -2
- package/docs/llms-full.txt +481 -61
- package/docs/types.json +147 -70
- package/package.json +11 -11
- package/src/assure-hydration.ts +4 -1
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +33 -7
- package/src/check-diagnostics.ts +2 -0
- package/src/check-scan.ts +56 -9
- package/src/check.ts +55 -39
- package/src/cli.ts +104 -35
- package/src/client-module.ts +45 -9
- package/src/command-catalog.ts +77 -21
- package/src/dev-feed-tool.ts +112 -1
- package/src/fix-recipes.ts +101 -10
- package/src/init-agents.ts +4 -3
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/project-specifiers.ts +69 -16
- package/src/internal/source-facts.ts +23 -5
- package/src/internal/typescript-import.ts +5 -3
- package/src/introspect.ts +39 -1
- package/src/levels-tool.ts +7 -9
- package/src/load.ts +29 -3
- package/src/mcp-context.ts +5 -18
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +9 -2
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +33 -0
- package/src/mcp-path.ts +8 -22
- package/src/mcp.ts +48 -19
- package/src/migrate-layout.ts +1496 -237
- package/src/output-schemas.ts +179 -23
- package/src/project-path.ts +44 -13
- package/src/release-verification.ts +20 -0
- package/src/review.ts +5 -0
- package/src/rules/codes.ts +3 -1
- package/src/rules/data-guard.ts +40 -0
- package/src/rules/legacy.ts +22 -1
- package/src/rules/pages.ts +266 -24
- package/src/rules/security.ts +49 -11
- package/src/rules/zones.ts +30 -7
- package/src/stylex-migrate.ts +117 -17
- package/src/test-tool.ts +3 -9
- package/src/upgrade.ts +96 -8
- package/src/verification-plan.ts +9 -2
- package/src/version-drift.ts +53 -2
|
@@ -82,8 +82,26 @@ function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boole
|
|
|
82
82
|
)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export
|
|
85
|
+
export interface SourceFactsOptions {
|
|
86
|
+
/** Whether the compiler settings for a parsed file keep an import whose named specifiers are all
|
|
87
|
+
* type-only (`verbatimModuleSyntax`). Without it such an import is erased. */
|
|
88
|
+
readonly keepsTypeOnlyImports?: (file: string) => boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSourceFacts(
|
|
92
|
+
ts: TypeScriptApi,
|
|
93
|
+
options: SourceFactsOptions = {},
|
|
94
|
+
): SourceFacts {
|
|
86
95
|
const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
|
|
96
|
+
// `{}` loads its module in every bundler; a list of type-only specifiers is erased unless the
|
|
97
|
+
// compiler keeps it as `import {} from "x"`.
|
|
98
|
+
const loadsModule = (
|
|
99
|
+
source: TSApi.SourceFile,
|
|
100
|
+
elements: readonly { readonly isTypeOnly: boolean }[],
|
|
101
|
+
): boolean =>
|
|
102
|
+
elements.length === 0 ||
|
|
103
|
+
elements.some((element) => !element.isTypeOnly) ||
|
|
104
|
+
(options.keepsTypeOnlyImports?.(source.fileName) ?? false)
|
|
87
105
|
|
|
88
106
|
const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
|
|
89
107
|
const cached = cache.get(file)
|
|
@@ -118,7 +136,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
118
136
|
if (reexport.isTypeOnly) return false
|
|
119
137
|
const named = reexport.exportClause
|
|
120
138
|
if (named !== undefined && ts.isNamedExports(named))
|
|
121
|
-
return named.elements
|
|
139
|
+
return loadsModule(source, named.elements)
|
|
122
140
|
return true // `export *` and `export * as ns`
|
|
123
141
|
}
|
|
124
142
|
const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
|
|
@@ -128,9 +146,9 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
128
146
|
const clause = declaration.importClause
|
|
129
147
|
if (clause === undefined) return true // side-effect import
|
|
130
148
|
if (clause.isTypeOnly) return false
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
// A default binding is a runtime import whatever its named list holds.
|
|
150
|
+
const named = clause.name === undefined ? clause.namedBindings : undefined
|
|
151
|
+
if (named !== undefined && ts.isNamedImports(named)) return loadsModule(source, named.elements)
|
|
134
152
|
return true // default and namespace bindings are runtime imports
|
|
135
153
|
}
|
|
136
154
|
|
|
@@ -323,9 +323,9 @@ function syntaxKinds(module: UnknownRecord, names: readonly string[]): ReadonlyS
|
|
|
323
323
|
|
|
324
324
|
function createVirtualFileSystem(
|
|
325
325
|
sourceFiles: ReadonlyMap<string, string>,
|
|
326
|
-
|
|
326
|
+
roots: readonly string[],
|
|
327
327
|
): UnknownRecord {
|
|
328
|
-
const rootPaths = pathVariants
|
|
328
|
+
const rootPaths = [...new Set(roots.flatMap(pathVariants))]
|
|
329
329
|
const virtualFiles: string[] = []
|
|
330
330
|
const seenFiles = new Set<string>()
|
|
331
331
|
for (const file of sourceFiles.keys()) {
|
|
@@ -454,7 +454,9 @@ async function createTypeScript7Session(
|
|
|
454
454
|
|
|
455
455
|
const api = new API({
|
|
456
456
|
cwd: sessionRoot,
|
|
457
|
-
|
|
457
|
+
// The root as the caller spells it too: a file not yet on disk keeps that spelling (`/var`, not
|
|
458
|
+
// `/private/var`), and the session reaches it through the caller's config.
|
|
459
|
+
fs: createVirtualFileSystem(files, [sessionRoot, root]),
|
|
458
460
|
})
|
|
459
461
|
let snapshot: TypeScript7Snapshot | undefined
|
|
460
462
|
let configuredProject: TypeScript7Project | undefined
|
package/src/introspect.ts
CHANGED
|
@@ -438,6 +438,9 @@ export interface RouteTableEntry {
|
|
|
438
438
|
* through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
|
|
439
439
|
* auto-mounted (they ARE the page router); only API routes carry this flag. */
|
|
440
440
|
readonly autoMounted?: boolean
|
|
441
|
+
/** True for an API route outside a set `apiPrefix` while `apiStrip` is off: the mount passes the
|
|
442
|
+
* request's full path, so no HTTP request reaches it; loaders still call it in-process. */
|
|
443
|
+
readonly inProcessOnly?: boolean
|
|
441
444
|
}
|
|
442
445
|
|
|
443
446
|
/** A page route + whether its module exports an `action` (→ it also serves POST). The caller resolves
|
|
@@ -506,6 +509,8 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
506
509
|
path,
|
|
507
510
|
methods: [...info.methods].sort(),
|
|
508
511
|
autoMounted: info.autoMounted,
|
|
512
|
+
// biome-ignore lint/plugin/noConditionalEmptyObjectSpread: only a route outside apiPrefix carries the flag, so every other row keeps its shape.
|
|
513
|
+
...(apiPrefix !== "" && !info.autoMounted ? { inProcessOnly: true } : {}),
|
|
509
514
|
})
|
|
510
515
|
}
|
|
511
516
|
return rows.sort(
|
|
@@ -544,7 +549,12 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
|
544
549
|
const display = rows.map((r) => ({
|
|
545
550
|
methods: r.methods.join(", "),
|
|
546
551
|
kind: r.kind,
|
|
547
|
-
path:
|
|
552
|
+
path:
|
|
553
|
+
r.kind === "api" && r.autoMounted
|
|
554
|
+
? `${r.path} (auto-mounted)`
|
|
555
|
+
: r.inProcessOnly === true
|
|
556
|
+
? `${r.path} (in-process only: outside apiPrefix)`
|
|
557
|
+
: r.path,
|
|
548
558
|
client: clientSpellingFor(r.path),
|
|
549
559
|
}))
|
|
550
560
|
const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
|
|
@@ -577,6 +587,7 @@ export interface RouteJsonRow {
|
|
|
577
587
|
readonly methods: readonly string[]
|
|
578
588
|
readonly file?: string
|
|
579
589
|
readonly autoMounted?: boolean
|
|
590
|
+
readonly inProcessOnly?: boolean
|
|
580
591
|
/** The reserved typed-client key a static segment of this path collides with, when one does. */
|
|
581
592
|
readonly reservedKey?: string
|
|
582
593
|
/** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
|
|
@@ -599,6 +610,8 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
|
599
610
|
methods: r.methods,
|
|
600
611
|
...(r.file !== undefined ? { file: r.file } : {}),
|
|
601
612
|
...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
|
|
613
|
+
// biome-ignore lint/plugin/noConditionalEmptyObjectSpread: `--json` rows gain the key only where it is true, so existing consumers see no change.
|
|
614
|
+
...(r.inProcessOnly === true ? { inProcessOnly: true } : {}),
|
|
602
615
|
...(client === undefined
|
|
603
616
|
? {}
|
|
604
617
|
: { reservedKey: client.reserved, clientSpelling: client.spelling }),
|
|
@@ -748,6 +761,31 @@ async function collectRouteTable(app: IntrospectedApp): Promise<RouteTableEntry[
|
|
|
748
761
|
return rows
|
|
749
762
|
}
|
|
750
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The warning `nifra dev` and `nifra build` print for backend routes no HTTP request reaches, read from
|
|
766
|
+
* the routes the backend registers, prefixes and mounts resolved; `undefined` when there are none.
|
|
767
|
+
*/
|
|
768
|
+
export function unservedRoutesWarning(
|
|
769
|
+
app: Pick<IntrospectedApp, "framework" | "backend">,
|
|
770
|
+
): string | undefined {
|
|
771
|
+
const apiPrefix = app.framework?.apiPrefix ?? ""
|
|
772
|
+
if (apiPrefix === "" || app.backend === undefined) return undefined
|
|
773
|
+
const rows = buildRouteTable({
|
|
774
|
+
pages: [],
|
|
775
|
+
api: backendRoutes(app.backend),
|
|
776
|
+
apiPrefix,
|
|
777
|
+
apiStrip: app.framework?.apiStrip === true,
|
|
778
|
+
}).filter((row) => row.inProcessOnly === true)
|
|
779
|
+
if (rows.length === 0) return undefined
|
|
780
|
+
const listed = rows.map((row) => `${row.methods.join(",")} ${row.path}`)
|
|
781
|
+
const named =
|
|
782
|
+
listed.length > 3
|
|
783
|
+
? `${listed.slice(0, 3).join(", ")} and ${listed.length - 3} more`
|
|
784
|
+
: listed.join(", ")
|
|
785
|
+
const one = rows.length === 1
|
|
786
|
+
return `[nifra] ${one ? "a backend route is" : `${rows.length} backend routes are`} served over HTTP nowhere: ${named}. backend/app.ts is mounted at apiPrefix ${apiPrefix} with apiStrip off, so it sees each request's full path, and ${one ? "this route sits" : "these sit"} outside it; loaders still call ${one ? "it" : "them"} in-process. Register ${one ? "it" : "them"} under ${apiPrefix}, or export \`apiStrip = true\` to serve every backend route at ${apiPrefix} plus its path.`
|
|
787
|
+
}
|
|
788
|
+
|
|
751
789
|
export async function describeRoutes(
|
|
752
790
|
app: IntrospectedApp,
|
|
753
791
|
opts: { readonly json?: boolean } = {},
|
package/src/levels-tool.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs"
|
|
|
15
15
|
import { resolve } from "node:path"
|
|
16
16
|
import type {
|
|
17
17
|
AssuranceReport,
|
|
18
|
+
AssuredRoute,
|
|
18
19
|
InvariantSetup,
|
|
19
20
|
InvariantSetupResult,
|
|
20
21
|
InvariantsConfig,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
type ContractTestApp,
|
|
27
28
|
runAdversarialContract,
|
|
28
29
|
} from "@nifrajs/testing"
|
|
30
|
+
import { RESPONSE_CONTRACT_BASELINE_HINT, responseContractGaps } from "./assure.ts"
|
|
29
31
|
import {
|
|
30
32
|
type CapabilityProjectReport,
|
|
31
33
|
diffCapabilitySnapshots,
|
|
@@ -310,7 +312,7 @@ const messageOf = (error: unknown): string =>
|
|
|
310
312
|
|
|
311
313
|
function finalize(
|
|
312
314
|
statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
|
|
313
|
-
routes: readonly
|
|
315
|
+
routes: readonly AssuredRoute[],
|
|
314
316
|
): VerificationLevelsResult {
|
|
315
317
|
// Cumulative: the achieved level is the last rung with every rung below it also holding.
|
|
316
318
|
let achieved = -1
|
|
@@ -322,6 +324,7 @@ function finalize(
|
|
|
322
324
|
const enforced = declared.filter((route) => route.responseContract === "enforce").length
|
|
323
325
|
const warnOnly = declared.filter((route) => route.responseContract === "warn").length
|
|
324
326
|
const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
|
|
327
|
+
const gaps = responseContractGaps(routes)
|
|
325
328
|
const responseContracts: ResponseContractStatus = {
|
|
326
329
|
declared: declared.length,
|
|
327
330
|
enforced,
|
|
@@ -333,14 +336,9 @@ function finalize(
|
|
|
333
336
|
declared.length === 0
|
|
334
337
|
? ["no response/error schemas declared"]
|
|
335
338
|
: [
|
|
336
|
-
...(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
]
|
|
340
|
-
: []),
|
|
341
|
-
...(warnOnly > 0
|
|
342
|
-
? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
|
|
343
|
-
: []),
|
|
339
|
+
...(gaps.typedOnlyReason === undefined ? [] : [gaps.typedOnlyReason]),
|
|
340
|
+
...(gaps.warnOnlyReason === undefined ? [] : [gaps.warnOnlyReason]),
|
|
341
|
+
...(typedOnly + warnOnly > 0 ? [RESPONSE_CONTRACT_BASELINE_HINT] : []),
|
|
344
342
|
],
|
|
345
343
|
}
|
|
346
344
|
return Object.freeze({
|
package/src/load.ts
CHANGED
|
@@ -342,9 +342,31 @@ export async function loadBackendApp(
|
|
|
342
342
|
return { cwd, backend }
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/** The fields the generated server entry imports from backend/framework.ts. */
|
|
346
|
+
const SERVER_OPTIONS = [...FRAMEWORK_WEB_OPTIONS, "use"] as const
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* `config` with each server option it leaves unset taken from backend/framework.ts, so `nifra dev` and
|
|
350
|
+
* the server entry `nifra build` generates read one value without nifra.config.ts re-exporting it.
|
|
351
|
+
*/
|
|
352
|
+
async function withServerOptions(
|
|
353
|
+
config: Partial<NifraFramework>,
|
|
354
|
+
frameworkPath: string,
|
|
355
|
+
importQuery: string | undefined,
|
|
356
|
+
): Promise<Partial<NifraFramework>> {
|
|
357
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a config module, read like nifra.config.ts; every web option taken from it is validated below.
|
|
358
|
+
const framework = (await importWithQuery(frameworkPath, importQuery)) as Partial<NifraFramework>
|
|
359
|
+
const taken: Record<string, unknown> = {}
|
|
360
|
+
for (const name of SERVER_OPTIONS) {
|
|
361
|
+
if (config[name] === undefined && framework[name] !== undefined) taken[name] = framework[name]
|
|
362
|
+
}
|
|
363
|
+
assertFrameworkWebOptions(taken, FRAMEWORK_FILE)
|
|
364
|
+
return { ...config, ...taken }
|
|
365
|
+
}
|
|
366
|
+
|
|
345
367
|
/** Discover + validate the app conventions rooted at `cwd`. Prefers `nifra.config.ts`, falls back
|
|
346
|
-
* to
|
|
347
|
-
*
|
|
368
|
+
* to `backend/framework.ts`; with both, a server option nifra.config.ts leaves unset comes from
|
|
369
|
+
* backend/framework.ts. Throws a clear, actionable error if neither exists or the config is
|
|
348
370
|
* malformed. */
|
|
349
371
|
export async function loadApp(
|
|
350
372
|
cwd: string,
|
|
@@ -375,7 +397,11 @@ export async function loadApp(
|
|
|
375
397
|
`[nifra] no routes/ directory in ${cwd} - nifra apps are file-routed under routes/.`,
|
|
376
398
|
)
|
|
377
399
|
}
|
|
378
|
-
const
|
|
400
|
+
const loaded = (await importWithQuery(configPath, options.importQuery)) as Partial<NifraFramework>
|
|
401
|
+
const fw =
|
|
402
|
+
configFile === CONFIG_FILE && existsSync(resolve(cwd, FRAMEWORK_FILE))
|
|
403
|
+
? await withServerOptions(loaded, resolve(cwd, FRAMEWORK_FILE), options.importQuery)
|
|
404
|
+
: loaded
|
|
379
405
|
if (!isAdapter(fw.adapter) || typeof fw.clientModule !== "string") {
|
|
380
406
|
throw new Error(
|
|
381
407
|
`[nifra] ${configFile} must export \`adapter\` (a render adapter object) and \`clientModule\` (a string).`,
|
package/src/mcp-context.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { AppLoader } from "./load.ts"
|
|
|
12
12
|
import { createCachedAppLoader } from "./mcp-exec.ts"
|
|
13
13
|
import { resolveMcpProjectPath } from "./mcp-path.ts"
|
|
14
14
|
import type { McpPrompt, McpResource, McpServerFeatures, McpTool } from "./mcp-protocol.ts"
|
|
15
|
+
import { writeTargetOf } from "./project-path.ts"
|
|
15
16
|
|
|
16
17
|
function openApiFormat(args: Record<string, unknown>): "json" | "yaml" {
|
|
17
18
|
const format = args.format
|
|
@@ -186,24 +187,10 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
|
|
|
186
187
|
const lexical = relative(root, target)
|
|
187
188
|
return lexical === "" || (!lexical.startsWith("..") && !isAbsolute(lexical)) ? target : null
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const canonicalProbe = realpathSync(probe)
|
|
194
|
-
const rel = relative(canonicalRoot, canonicalProbe)
|
|
195
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return null
|
|
196
|
-
return missing.length === 0 ? canonicalProbe : resolve(canonicalProbe, ...missing.reverse())
|
|
197
|
-
} catch {
|
|
198
|
-
const parent = resolve(probe, "..")
|
|
199
|
-
if (parent === probe) return null
|
|
200
|
-
// `basename`, not a slice past `parent`: when `parent` is the filesystem root its trailing
|
|
201
|
-
// separator is part of it, so `parent.length + 1` eats the first character of the segment and
|
|
202
|
-
// the path gets rebuilt wrong.
|
|
203
|
-
missing.push(basename(probe))
|
|
204
|
-
probe = parent
|
|
205
|
-
}
|
|
206
|
-
}
|
|
190
|
+
const canonical = writeTargetOf(target)
|
|
191
|
+
if (canonical === undefined) return null
|
|
192
|
+
const rel = relative(canonicalRoot, canonical)
|
|
193
|
+
return rel.startsWith("..") || isAbsolute(rel) ? null : canonical
|
|
207
194
|
}
|
|
208
195
|
|
|
209
196
|
/** Consistent error string for a `dir` that escapes the project root. */
|
package/src/mcp-delegate.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* answer still reads as authoritative. So the server settles the version before it serves:
|
|
7
7
|
*
|
|
8
8
|
* 1. The project installs its own `@nifrajs/cli` at a different version: hand the whole session to
|
|
9
|
-
* that CLI (`
|
|
9
|
+
* that CLI (its `bin` script, run by this Bun with `--no-env-file`, stdio inherited) and step aside.
|
|
10
10
|
* 2. Handing off is impossible (no project CLI, a missing bin, or this process IS the hand-off and
|
|
11
11
|
* the versions still disagree): serve, but the version-sensitive tools refuse with the fix
|
|
12
12
|
* instead of answering for the wrong release.
|
|
@@ -31,27 +31,37 @@ export const VERSION_SENSITIVE_TOOLS: ReadonlySet<string> = new Set([
|
|
|
31
31
|
"nifra_contracts",
|
|
32
32
|
])
|
|
33
33
|
|
|
34
|
-
/** The project's own CLI: its version and, when
|
|
34
|
+
/** The project's own CLI: its version and, when it is on disk, the script its `bin` names. */
|
|
35
35
|
export interface ProjectCli {
|
|
36
36
|
readonly version: string
|
|
37
37
|
readonly bin?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const BIN_NAME = process.platform === "win32" ? "nifra.exe" : "nifra"
|
|
41
40
|
const MAX_ANCESTOR_DIRS = 8
|
|
42
41
|
|
|
42
|
+
function binScript(bin: unknown): string | undefined {
|
|
43
|
+
if (typeof bin === "string") return bin
|
|
44
|
+
if (typeof bin !== "object" || bin === null) return undefined
|
|
45
|
+
const named: unknown = Reflect.get(bin, "nifra")
|
|
46
|
+
return typeof named === "string" ? named : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** The nearest `@nifrajs/cli` install at or above `root` (a monorepo app hoists to the repo root). */
|
|
44
50
|
export async function findProjectCli(root: string): Promise<ProjectCli | undefined> {
|
|
45
51
|
let dir = root
|
|
46
52
|
for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
|
|
47
|
-
const
|
|
53
|
+
const pkg = join(dir, "node_modules", "@nifrajs", "cli")
|
|
48
54
|
try {
|
|
49
|
-
const meta = JSON.parse(
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
const meta = JSON.parse(await readFile(join(pkg, "package.json"), "utf8")) as {
|
|
56
|
+
version?: unknown
|
|
57
|
+
bin?: unknown
|
|
58
|
+
}
|
|
52
59
|
if (typeof meta.version === "string" && meta.version.length > 0) {
|
|
53
|
-
const
|
|
54
|
-
|
|
60
|
+
const script = binScript(meta.bin)
|
|
61
|
+
const bin = script === undefined ? undefined : join(pkg, script)
|
|
62
|
+
return bin !== undefined && existsSync(bin)
|
|
63
|
+
? { version: meta.version, bin }
|
|
64
|
+
: { version: meta.version }
|
|
55
65
|
}
|
|
56
66
|
} catch {
|
|
57
67
|
// Not installed at this level - keep walking up.
|
|
@@ -79,7 +89,9 @@ export async function delegateToProjectCli(options: {
|
|
|
79
89
|
const cli = await findProjectCli(options.root)
|
|
80
90
|
if (cli === undefined || cli.version === options.version || cli.bin === undefined)
|
|
81
91
|
return undefined
|
|
82
|
-
|
|
92
|
+
// Not the `.bin` shim: its `bun` would load the project's `.env`, and the inherited
|
|
93
|
+
// NIFRA_MCP_WITHOUT_ENV_FILES then tells that CLI the values are already gone.
|
|
94
|
+
return handOffSession([process.execPath, "--no-env-file", cli.bin, "mcp", ...options.args], {
|
|
83
95
|
cwd: options.cwd,
|
|
84
96
|
env: { ...process.env, [MCP_DELEGATED_ENV]: "1" },
|
|
85
97
|
announce: `[nifra] mcp: this CLI is ${options.version}; the project installs @nifrajs/cli ${cli.version} - serving this session from ${cli.bin}`,
|
package/src/mcp-exec.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
CHILD_INPUT_MAX_BYTES,
|
|
37
37
|
CHILD_OUTPUT_MAX_BYTES,
|
|
38
38
|
CHILD_TIMEOUT_MS,
|
|
39
|
+
type EscalatingStop,
|
|
40
|
+
escalatingStop,
|
|
39
41
|
OWN_PROCESS_GROUP,
|
|
40
42
|
readBoundedStream,
|
|
41
43
|
signalProcessGroup,
|
|
@@ -714,11 +716,12 @@ export async function spawnChild(
|
|
|
714
716
|
stderr: "pipe",
|
|
715
717
|
detached: OWN_PROCESS_GROUP,
|
|
716
718
|
})
|
|
719
|
+
const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
|
|
717
720
|
let killed = false
|
|
718
721
|
const kill = (): void => {
|
|
719
722
|
if (killed) return
|
|
720
723
|
killed = true
|
|
721
|
-
|
|
724
|
+
stopper.stop()
|
|
722
725
|
}
|
|
723
726
|
const abort = (): void => kill()
|
|
724
727
|
signal?.addEventListener("abort", abort, { once: true })
|
|
@@ -759,6 +762,7 @@ export async function spawnChild(
|
|
|
759
762
|
return text || `${label} failed:\n${err.text.trim() || "(no output)"}`
|
|
760
763
|
} finally {
|
|
761
764
|
clearTimeout(timer)
|
|
765
|
+
stopper.dispose()
|
|
762
766
|
signal?.removeEventListener("abort", abort)
|
|
763
767
|
}
|
|
764
768
|
}
|
|
@@ -838,6 +842,7 @@ export class WarmWorker {
|
|
|
838
842
|
readonly cleanup: () => void
|
|
839
843
|
}
|
|
840
844
|
>()
|
|
845
|
+
private readonly stopper: EscalatingStop
|
|
841
846
|
private stdoutBuffer = ""
|
|
842
847
|
private stdoutBufferBytes = 0
|
|
843
848
|
private stderrBuffer = ""
|
|
@@ -858,11 +863,13 @@ export class WarmWorker {
|
|
|
858
863
|
stdout: "pipe",
|
|
859
864
|
stderr: "pipe",
|
|
860
865
|
}) as PipeSubprocess
|
|
866
|
+
this.stopper = escalatingStop((name) => this.proc.kill(name))
|
|
861
867
|
liveWarmWorkers.add(this)
|
|
862
868
|
void this.readStdout()
|
|
863
869
|
void this.readStderr()
|
|
864
870
|
void this.proc.exited.then((code) => {
|
|
865
871
|
this.closed = true
|
|
872
|
+
this.stopper.dispose()
|
|
866
873
|
liveWarmWorkers.delete(this)
|
|
867
874
|
const err = new Error(
|
|
868
875
|
`warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
|
|
@@ -876,7 +883,7 @@ export class WarmWorker {
|
|
|
876
883
|
}
|
|
877
884
|
|
|
878
885
|
stop(): void {
|
|
879
|
-
if (!this.closed) this.
|
|
886
|
+
if (!this.closed) this.stopper.stop()
|
|
880
887
|
}
|
|
881
888
|
|
|
882
889
|
async request(input: unknown, signal?: AbortSignal): Promise<string> {
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "5.
|
|
32
|
+
const VERSION = "5.1.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|
package/src/mcp-io.ts
CHANGED
|
@@ -36,6 +36,39 @@ export function signalProcessGroup(
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** How long a stopped child gets to exit after SIGTERM before it is sent SIGKILL. */
|
|
40
|
+
export const KILL_GRACE_MS = 2_000
|
|
41
|
+
|
|
42
|
+
/** Stopping a child: `stop` sends SIGTERM, `dispose` drops the SIGKILL still pending once it is done. */
|
|
43
|
+
export interface EscalatingStop {
|
|
44
|
+
readonly stop: () => void
|
|
45
|
+
readonly dispose: () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stop a child with SIGTERM, then SIGKILL once `graceMs` passes. Project code can catch SIGTERM
|
|
50
|
+
* (a graceful-shutdown hook waiting on a hung request), and a child that never exits keeps its
|
|
51
|
+
* caller waiting and outlives it. `send` delivers one signal to the child or its group.
|
|
52
|
+
*/
|
|
53
|
+
export function escalatingStop(
|
|
54
|
+
send: (signal: NodeJS.Signals) => void,
|
|
55
|
+
graceMs: number = KILL_GRACE_MS,
|
|
56
|
+
): EscalatingStop {
|
|
57
|
+
let escalation: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
let disposed = false
|
|
59
|
+
return {
|
|
60
|
+
stop: () => {
|
|
61
|
+
if (disposed) return
|
|
62
|
+
send("SIGTERM")
|
|
63
|
+
escalation ??= setTimeout(() => send("SIGKILL"), graceMs)
|
|
64
|
+
},
|
|
65
|
+
dispose: () => {
|
|
66
|
+
disposed = true
|
|
67
|
+
clearTimeout(escalation)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
|
|
40
73
|
|
|
41
74
|
/**
|
package/src/mcp-path.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs"
|
|
2
|
-
import {
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path"
|
|
3
|
+
import { writeTargetOf } from "./project-path.ts"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
|
|
6
7
|
*
|
|
7
8
|
* MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
|
|
8
9
|
* capability boundary. The nearest-existing-ancestor walk also supports output files that do not
|
|
9
|
-
* exist yet while still canonicalizing every existing path component
|
|
10
|
+
* exist yet while still canonicalizing every existing path component, and a dangling symlink is
|
|
11
|
+
* judged by where it points.
|
|
10
12
|
*/
|
|
11
13
|
export function resolveMcpProjectPath(root: string, value: string): string | null {
|
|
12
14
|
if (value.length === 0 || isAbsolute(value)) return null
|
|
@@ -23,26 +25,10 @@ export function resolveMcpProjectPath(root: string, value: string): string | nul
|
|
|
23
25
|
return null
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const canonicalProbe = realpathSync(probe)
|
|
31
|
-
const canonicalRelative = relative(canonicalRoot, canonicalProbe)
|
|
32
|
-
if (canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) return null
|
|
33
|
-
|
|
34
|
-
const candidate = resolve(canonicalProbe, ...missing.reverse())
|
|
35
|
-
const candidateRelative = relative(canonicalRoot, candidate)
|
|
36
|
-
return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
|
|
37
|
-
} catch (error) {
|
|
38
|
-
if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT"))
|
|
39
|
-
return null
|
|
40
|
-
const parent = resolve(probe, "..")
|
|
41
|
-
if (parent === probe) return null
|
|
42
|
-
missing.push(basename(probe))
|
|
43
|
-
probe = parent
|
|
44
|
-
}
|
|
45
|
-
}
|
|
28
|
+
const candidate = writeTargetOf(target)
|
|
29
|
+
if (candidate === undefined) return null
|
|
30
|
+
const candidateRelative = relative(canonicalRoot, candidate)
|
|
31
|
+
return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
|
|
46
32
|
}
|
|
47
33
|
|
|
48
34
|
export function mcpProjectPathError(label: string, value: string): string {
|
package/src/mcp.ts
CHANGED
|
@@ -181,6 +181,13 @@ function appFeatures(cwd: string, surface: IsolatedSurface): McpServerFeatures {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
const ROOTS_REQUEST_ID = "nifra:roots/list"
|
|
184
|
+
/** Messages whose answer reads nothing the app declares: the handshake is never held up by its import. */
|
|
185
|
+
const ANSWERED_WITHOUT_THE_APP: ReadonlySet<string> = new Set([
|
|
186
|
+
"initialize",
|
|
187
|
+
"ping",
|
|
188
|
+
"notifications/initialized",
|
|
189
|
+
"notifications/roots/list_changed",
|
|
190
|
+
])
|
|
184
191
|
const MAX_STDIO_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
185
192
|
const STDIO_ENCODER = new TextEncoder()
|
|
186
193
|
|
|
@@ -207,8 +214,12 @@ async function createProjectContext(root: string): Promise<ProjectContext> {
|
|
|
207
214
|
)
|
|
208
215
|
const allResources: McpResource[] = []
|
|
209
216
|
const allPrompts: McpPrompt[] = []
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
// Each app loads in its own subprocess, so they load side by side.
|
|
218
|
+
const loaded = await Promise.all(
|
|
219
|
+
apps.map(async (app) => ({ app, surface: await app.surface() })),
|
|
220
|
+
)
|
|
221
|
+
for (const { app, surface } of loaded) {
|
|
222
|
+
const ns = namespaceForApp(app.name, [], appFeatures(app.cwd, surface))
|
|
212
223
|
allResources.push(...(ns.features.resources ?? []))
|
|
213
224
|
allPrompts.push(...(ns.features.prompts ?? []))
|
|
214
225
|
}
|
|
@@ -251,7 +262,9 @@ export async function runMcpServer(
|
|
|
251
262
|
return
|
|
252
263
|
}
|
|
253
264
|
}
|
|
254
|
-
|
|
265
|
+
// Loaded in the background: the app's import can take seconds, and the handshake needs none of it.
|
|
266
|
+
let ctx = createProjectContext(rootState.root)
|
|
267
|
+
ctx.catch(() => {})
|
|
255
268
|
let drift: ToolingDrift | undefined = await detectToolingDrift(rootState.root, version)
|
|
256
269
|
const serverInfo = { name: "nifra", version }
|
|
257
270
|
const state = createMcpProtocolState()
|
|
@@ -286,7 +299,8 @@ export async function runMcpServer(
|
|
|
286
299
|
if (result === undefined) return
|
|
287
300
|
const next = await applyClientRoots(rootState, pathsFromRootsResult(result))
|
|
288
301
|
if (next.root !== rootState.root) {
|
|
289
|
-
ctx =
|
|
302
|
+
ctx = createProjectContext(next.root)
|
|
303
|
+
ctx.catch(() => {})
|
|
290
304
|
drift = await detectToolingDrift(next.root, version)
|
|
291
305
|
}
|
|
292
306
|
rootState = next
|
|
@@ -313,15 +327,38 @@ export async function runMcpServer(
|
|
|
313
327
|
// the code it started with: re-read the installed version per call so no answer is for a stale one.
|
|
314
328
|
if (message.method === "tools/call") drift = await detectToolingDrift(rootState.root, version)
|
|
315
329
|
|
|
330
|
+
const method = message.method ?? ""
|
|
331
|
+
if (ANSWERED_WITHOUT_THE_APP.has(method)) {
|
|
332
|
+
const response = await handleRpc(
|
|
333
|
+
message,
|
|
334
|
+
[],
|
|
335
|
+
serverInfo,
|
|
336
|
+
{
|
|
337
|
+
resources: projectResources(rootState.root, noAppInServer),
|
|
338
|
+
prompts: projectPrompts(),
|
|
339
|
+
instructions: rootInstructions(rootState, drift),
|
|
340
|
+
},
|
|
341
|
+
{ state, sendNotification: send, exposeToolErrors: true },
|
|
342
|
+
)
|
|
343
|
+
if (response) send(response)
|
|
344
|
+
// Ask for the client's workspace roots once the handshake completes, and again whenever the
|
|
345
|
+
// client says they changed. The answer comes back through the interception above.
|
|
346
|
+
if (method.startsWith("notifications/")) requestRoots()
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const project = await ctx
|
|
316
350
|
let activeTools: McpTool[]
|
|
317
|
-
if (
|
|
351
|
+
if (project.apps) {
|
|
352
|
+
const loaded = await Promise.all(
|
|
353
|
+
project.apps.map(async (app) => ({ app, surface: await app.surface() })),
|
|
354
|
+
)
|
|
318
355
|
const allTools: McpTool[] = []
|
|
319
|
-
for (const {
|
|
356
|
+
for (const { app, surface } of loaded) {
|
|
320
357
|
const tools = [
|
|
321
|
-
...refuseVersionSensitive(isolatedProjectTools(
|
|
322
|
-
...
|
|
358
|
+
...refuseVersionSensitive(isolatedProjectTools(app.cwd), drift),
|
|
359
|
+
...surface.tools,
|
|
323
360
|
]
|
|
324
|
-
const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
|
|
361
|
+
const ns = namespaceForApp(app.name, tools, { resources: [], prompts: [] })
|
|
325
362
|
allTools.push(...ns.tools)
|
|
326
363
|
}
|
|
327
364
|
activeTools = [
|
|
@@ -334,7 +371,7 @@ export async function runMcpServer(
|
|
|
334
371
|
} else {
|
|
335
372
|
activeTools = [
|
|
336
373
|
...refuseVersionSensitive(isolatedProjectTools(rootState.root), drift),
|
|
337
|
-
...(await
|
|
374
|
+
...(await project.surface()).tools,
|
|
338
375
|
]
|
|
339
376
|
}
|
|
340
377
|
const verdict = await rootVerdict(rootState)
|
|
@@ -346,7 +383,7 @@ export async function runMcpServer(
|
|
|
346
383
|
)
|
|
347
384
|
|
|
348
385
|
const features: McpServerFeatures = {
|
|
349
|
-
...
|
|
386
|
+
...project.features,
|
|
350
387
|
instructions: rootInstructions(rootState, drift),
|
|
351
388
|
}
|
|
352
389
|
const response = await handleRpc(message, activeTools, serverInfo, features, {
|
|
@@ -356,14 +393,6 @@ export async function runMcpServer(
|
|
|
356
393
|
exposeToolErrors: true,
|
|
357
394
|
})
|
|
358
395
|
if (response) send(response)
|
|
359
|
-
// Ask for the client's workspace roots once the handshake completes, and again whenever the
|
|
360
|
-
// client says they changed. The answer comes back through the interception above.
|
|
361
|
-
if (
|
|
362
|
-
message.method === "notifications/initialized" ||
|
|
363
|
-
message.method === "notifications/roots/list_changed"
|
|
364
|
-
) {
|
|
365
|
-
requestRoots()
|
|
366
|
-
}
|
|
367
396
|
}
|
|
368
397
|
|
|
369
398
|
const decoder = new TextDecoder()
|