@nifrajs/cli 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
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,12 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.2.2",
45
- "@nifrajs/mcp": "1.2.2",
46
- "@nifrajs/runner": "1.2.2",
47
- "@nifrajs/schema": "1.2.2",
48
- "@nifrajs/web": "1.2.2",
49
- "create-nifra": "1.2.2"
44
+ "@nifrajs/client": "1.3.1",
45
+ "@nifrajs/mcp": "1.3.1",
46
+ "@nifrajs/runner": "1.3.1",
47
+ "@nifrajs/schema": "1.3.1",
48
+ "@nifrajs/web": "1.3.1",
49
+ "create-nifra": "1.3.1"
50
50
  },
51
51
  "repository": {
52
52
  "type": "git",
package/src/cli.ts CHANGED
@@ -82,7 +82,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
82
82
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
83
83
 
84
84
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
85
- const CLI_VERSION = "1.2.2"
85
+ const CLI_VERSION = "1.3.1"
86
86
 
87
87
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
88
88
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
package/src/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.2.2"
29
+ const VERSION = "1.3.1"
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
@@ -40,10 +40,12 @@ import {
40
40
  type JsonRpcRequest,
41
41
  type JsonRpcResponse,
42
42
  type McpPrompt,
43
+ type McpPromptMessage,
43
44
  type McpResource,
44
45
  type McpServerFeatures,
45
46
  type McpTool,
46
47
  type McpToolContext,
48
+ type McpToolResult,
47
49
  rpcError,
48
50
  } from "./mcp-protocol.ts"
49
51
  import { loadTypesCorpus } from "./types-search.ts"
@@ -566,6 +568,27 @@ export function projectFeatures(
566
568
  return { resources: projectResources(cwd, loadAppCached), prompts: projectPrompts() }
567
569
  }
568
570
 
571
+ /**
572
+ * Resolve an optional `dir` tool argument — a subdirectory of the project root the caller wants to scope a
573
+ * check/test to (e.g. `nifra check` on `app/` when the MCP server's root is a monorepo). Returns the
574
+ * absolute target, or `null` if `dir` escapes the root (a path-traversal guard: no `..` out, no absolute
575
+ * path elsewhere). `undefined`/empty → the root itself.
576
+ */
577
+ export function resolveProjectDir(root: string, dir: string | undefined): string | null {
578
+ if (dir === undefined || dir === "") return root
579
+ const target = resolve(root, dir)
580
+ return target === root || target.startsWith(root + sep) ? target : null
581
+ }
582
+
583
+ /** Consistent error string for a `dir` that escapes the project root. */
584
+ function dirError(dir: string | undefined): string {
585
+ return JSON.stringify(
586
+ { ok: false, error: `dir must be a subdirectory of the project root — "${dir}" escapes it.` },
587
+ null,
588
+ 2,
589
+ )
590
+ }
591
+
569
592
  /** Build the project-scoped tools for `cwd`. */
570
593
  export function projectTools(
571
594
  cwd: string,
@@ -755,13 +778,21 @@ export function projectTools(
755
778
  type: "number",
756
779
  description: "Timeout in milliseconds (default 30000, max 300000).",
757
780
  },
781
+ dir: {
782
+ type: "string",
783
+ description:
784
+ 'Run the tests in this subdirectory (relative to the project root), e.g. "app". Use it when the MCP server\'s root is a monorepo but you want to test just one app. Default: the project root.',
785
+ },
758
786
  },
759
787
  additionalProperties: false,
760
788
  },
761
789
  handler: async (args, context) => {
762
790
  const { collectTestResult } = await import("./test-tool.ts")
791
+ const opts = args as { dir?: string }
792
+ const target = resolveProjectDir(cwd, opts.dir)
793
+ if (target === null) return dirError(opts.dir)
763
794
  return JSON.stringify(
764
- await collectTestResult(cwd, args, { signal: context.signal }),
795
+ await collectTestResult(target, args, { signal: context.signal }),
765
796
  null,
766
797
  2,
767
798
  )
@@ -817,14 +848,21 @@ export function projectTools(
817
848
  type: "boolean",
818
849
  description: "Skip tsc; run only the near-instant source lints (inner-loop mode).",
819
850
  },
851
+ dir: {
852
+ type: "string",
853
+ description:
854
+ 'Run the check in this subdirectory (relative to the project root), e.g. "app" or "packages/api". Use it when the MCP server\'s root is a monorepo but you want to check just one app. Default: the project root.',
855
+ },
820
856
  },
821
857
  additionalProperties: false,
822
858
  },
823
859
  handler: async (args, context) => {
824
860
  const { collectCheckResult } = await import("./check.ts")
825
- const opts = args as { lintsOnly?: boolean }
861
+ const opts = args as { lintsOnly?: boolean; dir?: string }
862
+ const target = resolveProjectDir(cwd, opts.dir)
863
+ if (target === null) return dirError(opts.dir)
826
864
  return JSON.stringify(
827
- await collectCheckResult(cwd, {
865
+ await collectCheckResult(target, {
828
866
  lintsOnly: opts.lintsOnly ?? false,
829
867
  signal: context.signal,
830
868
  // Bound the result so a large project can't emit an MCP message big enough to break the stdio
@@ -837,6 +875,81 @@ export function projectTools(
837
875
  )
838
876
  },
839
877
  },
878
+ {
879
+ name: "nifra_fix",
880
+ description:
881
+ "Automatically fix diagnostic lints (such as rewriting hand-rolled fetch() calls to the typed nifra client, adding generic types to client factory calls, and resolving dependency drift in package.json). Runs diagnostics, applies all mechanical edit suggestions, applies doctor dependency fixes, and returns the remaining unresolved diagnostics.",
882
+ inputSchema: {
883
+ type: "object",
884
+ properties: {},
885
+ additionalProperties: false,
886
+ },
887
+ handler: async (_args, context) => {
888
+ const { collectCheckResult } = await import("./check.ts")
889
+ const { applyDoctorAutoFix } = await import("./doctor.ts")
890
+ const { writeFile, readFile } = await import("node:fs/promises")
891
+ const { resolve } = await import("node:path")
892
+
893
+ let doctorResult: Awaited<ReturnType<typeof applyDoctorAutoFix>> | null = null
894
+ try {
895
+ doctorResult = await applyDoctorAutoFix(cwd)
896
+ } catch {
897
+ // ignore doctor errors
898
+ }
899
+
900
+ const checkResult = await collectCheckResult(cwd, {
901
+ lintsOnly: true,
902
+ signal: context.signal,
903
+ maxDiagnostics: 100,
904
+ })
905
+
906
+ const fixed: Array<{ file: string; line: number; title: string }> = []
907
+
908
+ for (const diag of checkResult.diagnostics) {
909
+ if (diag.file && diag.line && diag.suggestion?.kind === "edit" && diag.suggestion.diff) {
910
+ try {
911
+ const diffLines = diag.suggestion.diff.split("\n")
912
+ const beforeLine = diffLines.find((l) => l.startsWith("-"))?.slice(1)
913
+ const afterLine = diffLines.find((l) => l.startsWith("+"))?.slice(1)
914
+ if (beforeLine !== undefined && afterLine !== undefined) {
915
+ const filePath = resolve(cwd, diag.file)
916
+ const content = await readFile(filePath, "utf-8")
917
+ const lines = content.split("\n")
918
+ const idx = diag.line - 1
919
+ if (lines[idx] === beforeLine) {
920
+ lines[idx] = afterLine
921
+ await writeFile(filePath, lines.join("\n"), "utf-8")
922
+ fixed.push({
923
+ file: diag.file,
924
+ line: diag.line,
925
+ title: diag.suggestion.title,
926
+ })
927
+ }
928
+ }
929
+ } catch {
930
+ // ignore edit errors
931
+ }
932
+ }
933
+ }
934
+
935
+ const finalResult = await collectCheckResult(cwd, {
936
+ lintsOnly: false,
937
+ signal: context.signal,
938
+ maxDiagnostics: 100,
939
+ })
940
+
941
+ return JSON.stringify(
942
+ {
943
+ ok: finalResult.ok,
944
+ fixed,
945
+ doctorFixed: doctorResult?.fixed ?? [],
946
+ remainingDiagnostics: finalResult.diagnostics,
947
+ },
948
+ null,
949
+ 2,
950
+ )
951
+ },
952
+ },
840
953
  {
841
954
  name: "nifra_doctor",
842
955
  description:
@@ -865,6 +978,151 @@ export function projectTools(
865
978
  ]
866
979
  }
867
980
 
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
+ type ToolBackend = {
991
+ readonly routes?: () => ToolRoute[]
992
+ readonly fetch: (req: Request) => Promise<Response>
993
+ }
994
+
995
+ /** Extract tools registered via .tool() routes on the Nifra backend. Exported for the test that proves a
996
+ * `server().tool()` route surfaces in `tools/list` and executes through `tools/call`. */
997
+ export function extractBackendTools(backend: unknown): McpTool[] {
998
+ const b = backend as ToolBackend | null
999
+ if (!b || typeof b.routes !== "function") return []
1000
+ let routes: ToolRoute[] = []
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
+ }
1014
+
1015
+ return routes
1016
+ .filter(
1017
+ (
1018
+ r,
1019
+ ): r is ToolRoute & {
1020
+ schema: NonNullable<ToolRoute["schema"]>
1021
+ tool: NonNullable<ToolRoute["tool"]>
1022
+ } => r.schema !== undefined && r.tool !== undefined,
1023
+ )
1024
+ .map((r) => {
1025
+ const toolInfo = r.tool
1026
+ const s = r.schema
1027
+ return {
1028
+ name: toolInfo.name,
1029
+ description: toolInfo.description,
1030
+ inputSchema: (jsonSchemaOf(s.body) ?? {
1031
+ type: "object",
1032
+ properties: {},
1033
+ }) as McpTool["inputSchema"],
1034
+ ...(toolInfo.annotations !== undefined ? { annotations: toolInfo.annotations } : {}),
1035
+ handler: async (args): Promise<string | McpToolResult> => {
1036
+ const res = await b.fetch(
1037
+ new Request(`http://localhost/_nifra/tool/${toolInfo.name}`, {
1038
+ method: "POST",
1039
+ headers: { "content-type": "application/json" },
1040
+ body: JSON.stringify(args),
1041
+ }),
1042
+ )
1043
+ if (!res.ok) {
1044
+ const text = await res.text()
1045
+ throw new Error(`Tool execution failed (${res.status}): ${text}`)
1046
+ }
1047
+ const body: unknown = await res.json()
1048
+ if (typeof body === "string") return body
1049
+ if (body && typeof body === "object") {
1050
+ if ("content" in body || "structuredContent" in body) {
1051
+ return body as McpToolResult
1052
+ }
1053
+ return {
1054
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
1055
+ structuredContent: body as Record<string, unknown>,
1056
+ }
1057
+ }
1058
+ return String(body)
1059
+ },
1060
+ }
1061
+ })
1062
+ }
1063
+
1064
+ /** A resource declared on the backend via `server().resource()`, seen through `mcpResources()`. */
1065
+ type ResourceDescriptor = {
1066
+ readonly uri: string
1067
+ readonly name: string
1068
+ readonly description?: string
1069
+ readonly mimeType?: string
1070
+ readonly read: () => unknown | Promise<unknown>
1071
+ }
1072
+ type ResourceBackend = { readonly mcpResources?: () => readonly ResourceDescriptor[] }
1073
+
1074
+ /** Extract MCP resources registered via `.resource()` on the Nifra backend. Exported for the surfacing test. */
1075
+ export function extractBackendResources(backend: unknown): McpResource[] {
1076
+ const b = backend as ResourceBackend | null
1077
+ if (!b || typeof b.mcpResources !== "function") return []
1078
+ let list: readonly ResourceDescriptor[]
1079
+ try {
1080
+ list = b.mcpResources()
1081
+ } catch {
1082
+ return []
1083
+ }
1084
+ return list.map((r) => ({
1085
+ uri: r.uri,
1086
+ name: r.name,
1087
+ ...(r.description !== undefined ? { description: r.description } : {}),
1088
+ ...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
1089
+ read: async () => {
1090
+ const out = await r.read()
1091
+ if (typeof out === "string") return { text: out }
1092
+ const o = out as { text: string; mimeType?: string }
1093
+ return o.mimeType !== undefined ? { text: o.text, mimeType: o.mimeType } : { text: o.text }
1094
+ },
1095
+ }))
1096
+ }
1097
+
1098
+ /** A prompt declared on the backend via `server().prompt()`, seen through `mcpPrompts()`. */
1099
+ type PromptDescriptor = {
1100
+ readonly name: string
1101
+ readonly description: string
1102
+ readonly arguments?: readonly { name: string; description?: string; required?: boolean }[]
1103
+ readonly handler: (args: Record<string, string>) => unknown | Promise<unknown>
1104
+ }
1105
+ type PromptBackend = { readonly mcpPrompts?: () => readonly PromptDescriptor[] }
1106
+
1107
+ /** Extract MCP prompts registered via `.prompt()` on the Nifra backend. Exported for the surfacing test. */
1108
+ export function extractBackendPrompts(backend: unknown): McpPrompt[] {
1109
+ const b = backend as PromptBackend | null
1110
+ if (!b || typeof b.mcpPrompts !== "function") return []
1111
+ let list: readonly PromptDescriptor[]
1112
+ try {
1113
+ list = b.mcpPrompts()
1114
+ } catch {
1115
+ return []
1116
+ }
1117
+ return list.map((p) => ({
1118
+ name: p.name,
1119
+ description: p.description,
1120
+ ...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
1121
+ handler: async (args: Record<string, unknown>) =>
1122
+ (await p.handler(args as Record<string, string>)) as readonly McpPromptMessage[],
1123
+ }))
1124
+ }
1125
+
868
1126
  /** Prefix every tool name and resource URI for a named app in a monorepo. */
869
1127
  function namespaceForApp(
870
1128
  name: string,
@@ -872,7 +1130,10 @@ function namespaceForApp(
872
1130
  features: McpServerFeatures,
873
1131
  ): { tools: McpTool[]; features: McpServerFeatures } {
874
1132
  const prefix = `nifra_${name}_`
875
- const namespacedTools = tools.map((t) => ({ ...t, name: t.name.replace(/^nifra_/, prefix) }))
1133
+ const namespacedTools = tools.map((t) => ({
1134
+ ...t,
1135
+ name: t.name.startsWith("nifra_") ? t.name.replace(/^nifra_/, prefix) : `${name}_${t.name}`,
1136
+ }))
876
1137
  const namespacedResources = (features.resources ?? []).map((r) => ({
877
1138
  ...r,
878
1139
  uri: r.uri.replace(/^nifra:\/\//, `nifra://${name}/`),
@@ -889,40 +1150,65 @@ function namespaceForApp(
889
1150
 
890
1151
  /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
891
1152
  export async function runMcpServer(cwd: string, version: string): Promise<void> {
892
- let tools: McpTool[]
893
1153
  let features: McpServerFeatures
894
1154
 
895
1155
  const monorepo = await detectMonorepo(cwd)
896
1156
  if (monorepo) {
897
1157
  const appEntries = await loadMonorepoApps(cwd, monorepo)
898
- const allTools: McpTool[] = []
899
1158
  const allResources: McpResource[] = []
900
1159
  const allPrompts: McpPrompt[] = []
901
1160
  for (const { name, cwd: appCwd } of appEntries) {
902
1161
  const loader = createCachedAppLoader(appCwd)
903
- const ns = namespaceForApp(
904
- name,
905
- projectTools(appCwd, loader),
906
- projectFeatures(appCwd, loader),
907
- )
908
- allTools.push(...ns.tools)
1162
+ const app = await loader()
1163
+ const base = projectFeatures(appCwd, loader)
1164
+ const ns = namespaceForApp(name, [], {
1165
+ resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1166
+ prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1167
+ })
909
1168
  allResources.push(...(ns.features.resources ?? []))
910
1169
  allPrompts.push(...(ns.features.prompts ?? []))
911
1170
  }
912
- tools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
913
1171
  features = { resources: allResources, prompts: allPrompts }
914
1172
  } else {
915
1173
  const loadAppCached = createCachedAppLoader(cwd)
916
- tools = projectTools(cwd, loadAppCached)
917
- features = projectFeatures(cwd, loadAppCached)
1174
+ const base = projectFeatures(cwd, loadAppCached)
1175
+ const app = await loadAppCached()
1176
+ features = {
1177
+ resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1178
+ prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1179
+ }
918
1180
  }
1181
+ const loadAppCached = createCachedAppLoader(cwd)
919
1182
  const serverInfo = { name: "nifra", version }
920
1183
  const state = createMcpProtocolState()
921
1184
  const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
922
1185
  process.stdout.write(`${JSON.stringify(message)}\n`)
923
1186
  }
924
1187
  const dispatch = async (message: JsonRpcRequest): Promise<void> => {
925
- const response = await handleRpc(message, tools, serverInfo, features, {
1188
+ let activeTools: McpTool[]
1189
+ if (monorepo) {
1190
+ const appEntries = await loadMonorepoApps(cwd, monorepo)
1191
+ const allTools: McpTool[] = []
1192
+ for (const { name, cwd: appCwd } of appEntries) {
1193
+ const loader = createCachedAppLoader(appCwd)
1194
+ const app = await loader()
1195
+ const baseTools = projectTools(appCwd, loader)
1196
+ const backendTools = extractBackendTools(app.backend)
1197
+ const ns = namespaceForApp(name, [...baseTools, ...backendTools], {
1198
+ resources: [],
1199
+ prompts: [],
1200
+ })
1201
+ allTools.push(...ns.tools)
1202
+ }
1203
+ activeTools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
1204
+ } else {
1205
+ const app = await loadAppCached()
1206
+ const baseTools = projectTools(cwd, loadAppCached)
1207
+ const backendTools = extractBackendTools(app.backend)
1208
+ activeTools = [...baseTools, ...backendTools]
1209
+ }
1210
+
1211
+ const response = await handleRpc(message, activeTools, serverInfo, features, {
926
1212
  state,
927
1213
  sendNotification: send,
928
1214
  })