@nifrajs/cli 3.3.0 → 4.0.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 +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +7 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +162 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +58 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +323 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +145 -54
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +306 -87
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +60 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +298 -33
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +56 -16
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -4
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +93 -74
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +71 -16
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +5 -6
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +3 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +263 -131
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +20 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +113 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +64 -45
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +124 -25
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +159 -55
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +18 -5
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +165 -82
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +28 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +823 -0
- package/dist/review.js.map +1 -0
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +829 -34
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +154 -90
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +8 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +33 -16
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +4123 -548
- package/docs/types.json +14391 -10060
- package/package.json +19 -11
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +199 -797
- package/src/check-scan.ts +394 -63
- package/src/check.ts +146 -58
- package/src/cli.ts +347 -92
- package/src/command-catalog.ts +407 -45
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +66 -15
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diagnostics.ts +179 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +93 -82
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +144 -32
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +5 -7
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +301 -161
- package/src/mcp-http.ts +22 -5
- package/src/mcp-io.ts +130 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +72 -42
- package/src/mcp-root.ts +130 -24
- package/src/mcp-run.ts +190 -56
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +193 -91
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +42 -8
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +988 -0
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +947 -35
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +175 -98
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +27 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +40 -16
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 = "
|
|
32
|
+
const VERSION = "4.0.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 =>
|
|
@@ -40,10 +40,12 @@ const docsHealth = (tools: McpTool[]): string =>
|
|
|
40
40
|
/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text
|
|
41
41
|
* docs tools plus the `nifra_gallery` MCP Apps widget tool. */
|
|
42
42
|
export function publicDocsTools(): McpTool[] {
|
|
43
|
-
return [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
return [...openAiDocsTools(), examplesAppTool(loadExamplesCorpus)]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The text-only tool surface used by the public OpenAI Agent Plugin package. */
|
|
47
|
+
function openAiDocsTools(): McpTool[] {
|
|
48
|
+
return docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus)
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -59,9 +61,21 @@ export function respondMcpHttp(
|
|
|
59
61
|
return respondMcpHttpCore(request, tools, SERVER_INFO, { health: docsHealth(tools), ...options })
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** The text-only handler used by the OpenAI Agent Plugin surface. */
|
|
65
|
+
function handleOpenAiMcpHttp(request: Request): Promise<Response> {
|
|
66
|
+
return respondMcpHttp(request, openAiDocsTools(), {
|
|
67
|
+
features: {
|
|
68
|
+
instructions:
|
|
69
|
+
"Nifra's public documentation tools for the OpenAI plugin. The server is stateless, read-only, and limited to Nifra's bundled documentation corpus, verified examples, API types, learning path, and frontend guidance.",
|
|
70
|
+
},
|
|
71
|
+
allowAnyOrigin: true,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`
|
|
63
76
|
* resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */
|
|
64
77
|
export function handleMcpHttp(request: Request): Promise<Response> {
|
|
78
|
+
if (new URL(request.url).pathname === "/openai") return handleOpenAiMcpHttp(request)
|
|
65
79
|
return respondMcpHttp(request, publicDocsTools(), {
|
|
66
80
|
features: {
|
|
67
81
|
resources: [examplesWidget.resource],
|
|
@@ -69,6 +83,9 @@ export function handleMcpHttp(request: Request): Promise<Response> {
|
|
|
69
83
|
instructions:
|
|
70
84
|
"nifra's own documentation, runnable examples, and API types - for building with the nifra framework. Call nifra_docs / nifra_example / nifra_types / nifra_learn to learn it without a local checkout.",
|
|
71
85
|
},
|
|
86
|
+
// This is the intentionally public, secret-free docs server. Other MCP mounts use the
|
|
87
|
+
// transport's same-origin default unless they explicitly opt into public cross-origin access.
|
|
88
|
+
allowAnyOrigin: true,
|
|
72
89
|
})
|
|
73
90
|
}
|
|
74
91
|
|
package/src/mcp-io.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
export const CHILD_TIMEOUT_MS = 30_000
|
|
9
9
|
/** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
|
|
10
10
|
export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
|
|
11
|
+
/** Maximum serialized request sent to a project subprocess or warm worker. */
|
|
12
|
+
export const CHILD_INPUT_MAX_BYTES = 4 * 1024 * 1024
|
|
11
13
|
|
|
12
14
|
/** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
|
|
13
15
|
* buffer an unrelated loopback service just because a caller supplied its port. */
|
|
@@ -81,6 +83,134 @@ export interface BoundedOutput {
|
|
|
81
83
|
readonly truncated: boolean
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
export type BoundedLine =
|
|
87
|
+
| { readonly kind: "line"; readonly text: string }
|
|
88
|
+
| { readonly kind: "too-large" }
|
|
89
|
+
|
|
90
|
+
function decodeLine(chunks: readonly Uint8Array[], totalBytes: number): string {
|
|
91
|
+
const bytes = new Uint8Array(totalBytes)
|
|
92
|
+
let offset = 0
|
|
93
|
+
for (const chunk of chunks) {
|
|
94
|
+
bytes.set(chunk, offset)
|
|
95
|
+
offset += chunk.byteLength
|
|
96
|
+
}
|
|
97
|
+
return new TextDecoder().decode(bytes)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read newline-delimited input without retaining a line larger than the configured byte budget. */
|
|
101
|
+
export async function* readBoundedLines(
|
|
102
|
+
stream: ReadableStream<Uint8Array>,
|
|
103
|
+
maxBytes = CHILD_INPUT_MAX_BYTES,
|
|
104
|
+
): AsyncGenerator<BoundedLine> {
|
|
105
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
106
|
+
throw new RangeError("maxBytes must be a positive safe integer")
|
|
107
|
+
const chunks: Uint8Array[] = []
|
|
108
|
+
let totalBytes = 0
|
|
109
|
+
let tooLarge = false
|
|
110
|
+
for await (const raw of stream) {
|
|
111
|
+
const value = raw as Uint8Array
|
|
112
|
+
let start = 0
|
|
113
|
+
while (start < value.byteLength) {
|
|
114
|
+
const newline = value.indexOf(0x0a, start)
|
|
115
|
+
const end = newline === -1 ? value.byteLength : newline
|
|
116
|
+
const segment = value.subarray(start, end)
|
|
117
|
+
if (!tooLarge) {
|
|
118
|
+
if (totalBytes + segment.byteLength > maxBytes) {
|
|
119
|
+
tooLarge = true
|
|
120
|
+
chunks.length = 0
|
|
121
|
+
totalBytes = 0
|
|
122
|
+
} else if (segment.byteLength > 0) {
|
|
123
|
+
chunks.push(segment)
|
|
124
|
+
totalBytes += segment.byteLength
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (newline === -1) break
|
|
128
|
+
if (tooLarge) yield { kind: "too-large" }
|
|
129
|
+
else yield { kind: "line", text: decodeLine(chunks, totalBytes) }
|
|
130
|
+
chunks.length = 0
|
|
131
|
+
totalBytes = 0
|
|
132
|
+
tooLarge = false
|
|
133
|
+
start = newline + 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (tooLarge) yield { kind: "too-large" }
|
|
137
|
+
else if (totalBytes > 0) yield { kind: "line", text: decodeLine(chunks, totalBytes) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Serialize a JSON value only when its UTF-8 representation fits within the byte budget. */
|
|
141
|
+
export function serializeBoundedJson(
|
|
142
|
+
value: unknown,
|
|
143
|
+
maxBytes: number,
|
|
144
|
+
space?: string | number,
|
|
145
|
+
): string | undefined {
|
|
146
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return undefined
|
|
147
|
+
let text: string | undefined
|
|
148
|
+
try {
|
|
149
|
+
text = JSON.stringify(value, null, space)
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
if (text === undefined || new TextEncoder().encode(text).byteLength > maxBytes) return undefined
|
|
154
|
+
return text
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What {@link readHeadTail} kept: the start and end of a stream, and how many bytes fell between. */
|
|
158
|
+
export interface HeadTailOutput {
|
|
159
|
+
readonly head: string
|
|
160
|
+
readonly tail: string
|
|
161
|
+
/** Bytes dropped between `head` and `tail`; when 0, `head + tail` is the whole stream. */
|
|
162
|
+
readonly droppedBytes: number
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Drain a stream to its end, keeping at most `headBytes` from its start and `tailBytes` from its end,
|
|
167
|
+
* so a child that prints without limit costs bounded memory and still runs to completion.
|
|
168
|
+
*/
|
|
169
|
+
export async function readHeadTail(
|
|
170
|
+
stream: ReadableStream<Uint8Array>,
|
|
171
|
+
headBytes: number,
|
|
172
|
+
tailBytes: number,
|
|
173
|
+
): Promise<HeadTailOutput> {
|
|
174
|
+
const head: Uint8Array[] = []
|
|
175
|
+
let headSize = 0
|
|
176
|
+
const tail: Uint8Array[] = []
|
|
177
|
+
let tailSize = 0
|
|
178
|
+
let droppedBytes = 0
|
|
179
|
+
for await (let chunk of stream) {
|
|
180
|
+
if (headSize < headBytes) {
|
|
181
|
+
const taken = chunk.subarray(0, headBytes - headSize)
|
|
182
|
+
head.push(taken)
|
|
183
|
+
headSize += taken.byteLength
|
|
184
|
+
chunk = chunk.subarray(taken.byteLength)
|
|
185
|
+
}
|
|
186
|
+
if (chunk.byteLength === 0) continue
|
|
187
|
+
tail.push(chunk)
|
|
188
|
+
tailSize += chunk.byteLength
|
|
189
|
+
while (tailSize > tailBytes) {
|
|
190
|
+
const first = tail[0]!
|
|
191
|
+
const excess = tailSize - tailBytes
|
|
192
|
+
if (first.byteLength <= excess) {
|
|
193
|
+
tail.shift()
|
|
194
|
+
tailSize -= first.byteLength
|
|
195
|
+
droppedBytes += first.byteLength
|
|
196
|
+
} else {
|
|
197
|
+
tail[0] = first.subarray(excess)
|
|
198
|
+
tailSize -= excess
|
|
199
|
+
droppedBytes += excess
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const decode = (chunks: readonly Uint8Array[]): string => Buffer.concat(chunks).toString("utf8")
|
|
204
|
+
return { head: decode(head), tail: decode(tail), droppedBytes }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A {@link HeadTailOutput} as one string, marking where bytes were dropped. */
|
|
208
|
+
export function joinHeadTail(output: HeadTailOutput): string {
|
|
209
|
+
return output.droppedBytes === 0
|
|
210
|
+
? output.head + output.tail
|
|
211
|
+
: `${output.head}\n…(${output.droppedBytes} bytes omitted)…\n${output.tail}`
|
|
212
|
+
}
|
|
213
|
+
|
|
84
214
|
/** Read child output incrementally and cancel as soon as the byte budget is crossed. */
|
|
85
215
|
export async function readBoundedStream(
|
|
86
216
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `nifra mcp` keeps project code: in a fresh subprocess per call (`mcp-project-child.ts`), never
|
|
3
|
+
* in the server. The server lists the tools, resources and prompts and forwards each call; the child,
|
|
4
|
+
* started in the project's directory, loads `.env` there, imports the config and the backend, answers
|
|
5
|
+
* and exits. So the server holds neither the project's environment nor its modules, and every call
|
|
6
|
+
* sees the project's current code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto"
|
|
10
|
+
import { existsSync } from "node:fs"
|
|
11
|
+
import { resolve } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { CONFIG_FILE } from "./app-files.ts"
|
|
14
|
+
import type { NifraMonorepoConfig } from "./load.ts"
|
|
15
|
+
import { appFingerprint } from "./mcp-exec.ts"
|
|
16
|
+
import { CHILD_TIMEOUT_MS, readBoundedLines, readBoundedStream } from "./mcp-io.ts"
|
|
17
|
+
import type {
|
|
18
|
+
AppSurface,
|
|
19
|
+
ProjectChildAnswer,
|
|
20
|
+
ProjectChildMessage,
|
|
21
|
+
ProjectChildRequest,
|
|
22
|
+
} from "./mcp-project-child.ts"
|
|
23
|
+
import type {
|
|
24
|
+
McpPrompt,
|
|
25
|
+
McpPromptMessage,
|
|
26
|
+
McpResource,
|
|
27
|
+
McpTool,
|
|
28
|
+
McpToolContext,
|
|
29
|
+
McpToolResult,
|
|
30
|
+
} from "./mcp-protocol.ts"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Project tools that stay in the server, because none runs project code in this process: each starts
|
|
34
|
+
* its own subprocess (run, render, ws, hydrate, test, the database tools), or reads only the
|
|
35
|
+
* framework's docs or the dev server's feed. `mcp-project-isolation.test.ts` calls each one and fails
|
|
36
|
+
* if the server evaluated project code, or if a name here has no probe there.
|
|
37
|
+
*/
|
|
38
|
+
export const IN_PROCESS_TOOLS: ReadonlySet<string> = new Set([
|
|
39
|
+
"nifra_run",
|
|
40
|
+
"nifra_render",
|
|
41
|
+
"nifra_ws",
|
|
42
|
+
"nifra_hydrate",
|
|
43
|
+
"nifra_test",
|
|
44
|
+
"nifra_db_schema",
|
|
45
|
+
"nifra_db_query",
|
|
46
|
+
"nifra_db_role",
|
|
47
|
+
"nifra_errors",
|
|
48
|
+
"nifra_logs",
|
|
49
|
+
"nifra_inspect",
|
|
50
|
+
"nifra_explain",
|
|
51
|
+
"nifra_docs",
|
|
52
|
+
"nifra_example",
|
|
53
|
+
"nifra_types",
|
|
54
|
+
"nifra_learn",
|
|
55
|
+
"nifra_frontend",
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/** A tool result is bounded again by the stdio message limit when the server sends it. */
|
|
59
|
+
const ANSWER_MAX_BYTES = 8 * 1024 * 1024
|
|
60
|
+
|
|
61
|
+
function childPath(): string {
|
|
62
|
+
return fileURLToPath(new URL(import.meta.url)).replace(
|
|
63
|
+
/mcp-isolate\.(ts|js)$/,
|
|
64
|
+
"mcp-project-child.$1",
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
69
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
70
|
+
|
|
71
|
+
const isAnswer = (value: unknown): value is ProjectChildAnswer =>
|
|
72
|
+
isRecord(value) &&
|
|
73
|
+
(value.ok === true || (value.ok === false && typeof value.message === "string"))
|
|
74
|
+
|
|
75
|
+
function parseMessage(text: string): ProjectChildMessage | undefined {
|
|
76
|
+
let value: unknown
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(text)
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
if (!isRecord(value)) return undefined
|
|
83
|
+
if (value.type === "answer" && isAnswer(value.answer)) {
|
|
84
|
+
return { type: "answer", answer: value.answer }
|
|
85
|
+
}
|
|
86
|
+
if (value.type === "progress" && typeof value.progress === "number") {
|
|
87
|
+
return typeof value.total === "number"
|
|
88
|
+
? { type: "progress", progress: value.progress, total: value.total }
|
|
89
|
+
: { type: "progress", progress: value.progress }
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const failed = (message: string): ProjectChildAnswer => ({
|
|
95
|
+
ok: false,
|
|
96
|
+
message: `[nifra] ${message}`,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export interface ProjectChildOptions {
|
|
100
|
+
/** Progress is forwarded to it, and its cancellation kills the subprocess. */
|
|
101
|
+
readonly context?: McpToolContext
|
|
102
|
+
/** Kill the subprocess after this long. Tool calls have none: a client cancels them instead. */
|
|
103
|
+
readonly timeoutMs?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Answer `request` in a fresh subprocess in `cwd`; a crash, a timeout or no answer is `ok: false`. */
|
|
107
|
+
export async function askProjectChild(
|
|
108
|
+
cwd: string,
|
|
109
|
+
request: ProjectChildRequest,
|
|
110
|
+
options: ProjectChildOptions = {},
|
|
111
|
+
): Promise<ProjectChildAnswer> {
|
|
112
|
+
const { context, timeoutMs } = options
|
|
113
|
+
if (context?.signal.aborted) return failed("the call was cancelled before it started")
|
|
114
|
+
const token = randomUUID()
|
|
115
|
+
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">
|
|
116
|
+
try {
|
|
117
|
+
proc = Bun.spawn([process.execPath, childPath(), cwd], {
|
|
118
|
+
cwd,
|
|
119
|
+
// Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
|
|
120
|
+
env: process.env,
|
|
121
|
+
stdin: "pipe",
|
|
122
|
+
stdout: "pipe",
|
|
123
|
+
stderr: "pipe",
|
|
124
|
+
})
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return failed(
|
|
127
|
+
`cannot start a process in ${cwd}: ${error instanceof Error ? error.message : String(error)}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
let stopped: "timeout" | "cancel" | undefined
|
|
131
|
+
const stop = (reason: "timeout" | "cancel"): void => {
|
|
132
|
+
if (stopped !== undefined) return
|
|
133
|
+
stopped = reason
|
|
134
|
+
proc.kill("SIGKILL")
|
|
135
|
+
}
|
|
136
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => stop("timeout"), timeoutMs)
|
|
137
|
+
const onAbort = (): void => stop("cancel")
|
|
138
|
+
context?.signal.addEventListener("abort", onAbort, { once: true })
|
|
139
|
+
try {
|
|
140
|
+
proc.stdin.write(JSON.stringify({ ...request, token }))
|
|
141
|
+
await proc.stdin.end()
|
|
142
|
+
const stderr = readBoundedStream(proc.stderr, 16 * 1024)
|
|
143
|
+
let answer: ProjectChildAnswer | undefined
|
|
144
|
+
let tooLarge = false
|
|
145
|
+
for await (const item of readBoundedLines(proc.stdout, ANSWER_MAX_BYTES)) {
|
|
146
|
+
if (item.kind === "too-large") {
|
|
147
|
+
tooLarge = true
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (!item.text.startsWith(`${token} `)) continue
|
|
151
|
+
const message = parseMessage(item.text.slice(token.length + 1))
|
|
152
|
+
if (message?.type === "progress") context?.reportProgress(message.progress, message.total)
|
|
153
|
+
else if (message?.type === "answer") answer = message.answer
|
|
154
|
+
}
|
|
155
|
+
const [errors] = await Promise.all([stderr, proc.exited])
|
|
156
|
+
if (answer !== undefined) return answer
|
|
157
|
+
if (stopped === "timeout") {
|
|
158
|
+
return failed(`the project process did not answer within ${timeoutMs}ms and was killed`)
|
|
159
|
+
}
|
|
160
|
+
if (stopped === "cancel") return failed("the call was cancelled")
|
|
161
|
+
if (tooLarge) return failed(`the answer exceeded ${ANSWER_MAX_BYTES} bytes`)
|
|
162
|
+
const tail = errors.text.trim().slice(-600)
|
|
163
|
+
const how = proc.signalCode === null ? `with code ${proc.exitCode}` : `on ${proc.signalCode}`
|
|
164
|
+
return failed(
|
|
165
|
+
`the project process exited ${how} before answering${tail === "" ? "" : `: ${tail}`}`,
|
|
166
|
+
)
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer)
|
|
169
|
+
context?.signal.removeEventListener("abort", onAbort)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function callTool(
|
|
174
|
+
cwd: string,
|
|
175
|
+
name: string,
|
|
176
|
+
args: Record<string, unknown>,
|
|
177
|
+
context: McpToolContext,
|
|
178
|
+
): Promise<string | McpToolResult> {
|
|
179
|
+
const answer = await askProjectChild(cwd, { op: "tool", name, args }, { context })
|
|
180
|
+
if (!answer.ok) throw new Error(answer.message)
|
|
181
|
+
if ("result" in answer) return answer.result
|
|
182
|
+
throw new Error(`[nifra] the project process answered ${name} without a result`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readResource(
|
|
186
|
+
cwd: string,
|
|
187
|
+
uri: string,
|
|
188
|
+
): Promise<{ readonly text: string; readonly mimeType?: string }> {
|
|
189
|
+
const answer = await askProjectChild(
|
|
190
|
+
cwd,
|
|
191
|
+
{ op: "resource", uri },
|
|
192
|
+
{ timeoutMs: CHILD_TIMEOUT_MS },
|
|
193
|
+
)
|
|
194
|
+
if (!answer.ok) throw new Error(answer.message)
|
|
195
|
+
if ("resource" in answer) return answer.resource
|
|
196
|
+
throw new Error(`[nifra] the project process answered ${uri} without its text`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function getPrompt(
|
|
200
|
+
cwd: string,
|
|
201
|
+
name: string,
|
|
202
|
+
args: Record<string, unknown>,
|
|
203
|
+
): Promise<readonly McpPromptMessage[]> {
|
|
204
|
+
const answer = await askProjectChild(
|
|
205
|
+
cwd,
|
|
206
|
+
{ op: "prompt", name, args },
|
|
207
|
+
{ timeoutMs: CHILD_TIMEOUT_MS },
|
|
208
|
+
)
|
|
209
|
+
if (!answer.ok) throw new Error(answer.message)
|
|
210
|
+
if ("messages" in answer) return answer.messages
|
|
211
|
+
throw new Error(`[nifra] the project process answered ${name} without messages`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Each tool runs in a fresh project subprocess, unless it is one that runs no project code here. */
|
|
215
|
+
export function isolateTools(cwd: string, tools: readonly McpTool[]): McpTool[] {
|
|
216
|
+
return tools.map((tool) =>
|
|
217
|
+
IN_PROCESS_TOOLS.has(tool.name)
|
|
218
|
+
? tool
|
|
219
|
+
: { ...tool, handler: (args, context) => callTool(cwd, tool.name, args, context) },
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Each resource is read in a fresh project subprocess. */
|
|
224
|
+
export function isolateResources(cwd: string, resources: readonly McpResource[]): McpResource[] {
|
|
225
|
+
return resources.map((resource) => ({ ...resource, read: () => readResource(cwd, resource.uri) }))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The app's own tools, resources and prompts, each forwarded to a project subprocess. */
|
|
229
|
+
export interface IsolatedSurface {
|
|
230
|
+
readonly tools: readonly McpTool[]
|
|
231
|
+
readonly resources: readonly McpResource[]
|
|
232
|
+
readonly prompts: readonly McpPrompt[]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const EMPTY_SURFACE: AppSurface = { tools: [], resources: [], prompts: [] }
|
|
236
|
+
|
|
237
|
+
async function readSurface(cwd: string): Promise<IsolatedSurface> {
|
|
238
|
+
const answer = await askProjectChild(cwd, { op: "surface" }, { timeoutMs: CHILD_TIMEOUT_MS })
|
|
239
|
+
const surface = answer.ok && "surface" in answer ? answer.surface : EMPTY_SURFACE
|
|
240
|
+
return {
|
|
241
|
+
tools: surface.tools.map((tool) => ({
|
|
242
|
+
...tool,
|
|
243
|
+
handler: (args, context) => callTool(cwd, tool.name, args, context),
|
|
244
|
+
})),
|
|
245
|
+
resources: surface.resources.map((resource) => ({
|
|
246
|
+
...resource,
|
|
247
|
+
read: () => readResource(cwd, resource.uri),
|
|
248
|
+
})),
|
|
249
|
+
prompts: surface.prompts.map((prompt) => ({
|
|
250
|
+
...prompt,
|
|
251
|
+
handler: (args) => getPrompt(cwd, prompt.name, args),
|
|
252
|
+
})),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What the app declares on its backend (`app.tool(...)` and its resources and prompts), read in a
|
|
258
|
+
* project subprocess and kept until `nifra.config.ts`, `backend/framework.ts` or `backend/app.ts`
|
|
259
|
+
* changes. An app that cannot be loaded declares nothing: its absence must not withdraw the built-in
|
|
260
|
+
* tools, and a broken app must not start a subprocess on every message.
|
|
261
|
+
*/
|
|
262
|
+
export function createAppSurface(cwd: string): () => Promise<IsolatedSurface> {
|
|
263
|
+
let cached:
|
|
264
|
+
| { readonly fingerprint: string; readonly surface: Promise<IsolatedSurface> }
|
|
265
|
+
| undefined
|
|
266
|
+
return async () => {
|
|
267
|
+
const fingerprint = await appFingerprint(cwd).catch(() => "unreadable")
|
|
268
|
+
if (cached?.fingerprint !== fingerprint) {
|
|
269
|
+
cached = { fingerprint, surface: readSurface(cwd) }
|
|
270
|
+
}
|
|
271
|
+
return cached.surface
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `detectMonorepo`, with the root config evaluated in a project subprocess. Only a directory with a
|
|
277
|
+
* `nifra.config.ts` and no `routes/` can be a monorepo root, so no other directory starts one.
|
|
278
|
+
*/
|
|
279
|
+
export async function detectMonorepoIsolated(cwd: string): Promise<NifraMonorepoConfig | null> {
|
|
280
|
+
if (!existsSync(resolve(cwd, CONFIG_FILE)) || existsSync(resolve(cwd, "routes"))) return null
|
|
281
|
+
const answer = await askProjectChild(cwd, { op: "monorepo" }, { timeoutMs: CHILD_TIMEOUT_MS })
|
|
282
|
+
if (!answer.ok || !("apps" in answer) || answer.apps === null) return null
|
|
283
|
+
const apps = Object.entries(answer.apps).filter(
|
|
284
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
285
|
+
)
|
|
286
|
+
return { apps: Object.fromEntries(apps) }
|
|
287
|
+
}
|
package/src/mcp-path.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import { basename, isAbsolute, relative, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
|
|
6
|
+
*
|
|
7
|
+
* MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
|
|
8
|
+
* 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
|
+
*/
|
|
11
|
+
export function resolveMcpProjectPath(root: string, value: string): string | null {
|
|
12
|
+
if (value.length === 0 || isAbsolute(value)) return null
|
|
13
|
+
|
|
14
|
+
const lexicalRoot = resolve(root)
|
|
15
|
+
const target = resolve(lexicalRoot, value)
|
|
16
|
+
const lexicalRelative = relative(lexicalRoot, target)
|
|
17
|
+
if (lexicalRelative.startsWith("..") || isAbsolute(lexicalRelative)) return null
|
|
18
|
+
|
|
19
|
+
let canonicalRoot: string
|
|
20
|
+
try {
|
|
21
|
+
canonicalRoot = realpathSync(lexicalRoot)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let probe = target
|
|
27
|
+
const missing: string[] = []
|
|
28
|
+
while (true) {
|
|
29
|
+
try {
|
|
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mcpProjectPathError(label: string, value: string): string {
|
|
49
|
+
return `${label} must be a relative path inside the selected project directory: ${value}`
|
|
50
|
+
}
|