@nifrajs/cli 1.2.1 → 1.3.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/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,22 +848,108 @@ 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,
868
+ // Bound the result so a large project can't emit an MCP message big enough to break the stdio
869
+ // transport (`-32000: Connection closed`). If `truncated` comes back, fix the shown diagnostics
870
+ // and re-run. The scan already skips gitignored trees (walkSource), so this is the safety net.
871
+ maxDiagnostics: 100,
830
872
  }),
831
873
  null,
832
874
  2,
833
875
  )
834
876
  },
835
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
+ },
836
953
  {
837
954
  name: "nifra_doctor",
838
955
  description:
@@ -861,6 +978,151 @@ export function projectTools(
861
978
  ]
862
979
  }
863
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
+
864
1126
  /** Prefix every tool name and resource URI for a named app in a monorepo. */
865
1127
  function namespaceForApp(
866
1128
  name: string,
@@ -868,7 +1130,10 @@ function namespaceForApp(
868
1130
  features: McpServerFeatures,
869
1131
  ): { tools: McpTool[]; features: McpServerFeatures } {
870
1132
  const prefix = `nifra_${name}_`
871
- 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
+ }))
872
1137
  const namespacedResources = (features.resources ?? []).map((r) => ({
873
1138
  ...r,
874
1139
  uri: r.uri.replace(/^nifra:\/\//, `nifra://${name}/`),
@@ -885,40 +1150,65 @@ function namespaceForApp(
885
1150
 
886
1151
  /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
887
1152
  export async function runMcpServer(cwd: string, version: string): Promise<void> {
888
- let tools: McpTool[]
889
1153
  let features: McpServerFeatures
890
1154
 
891
1155
  const monorepo = await detectMonorepo(cwd)
892
1156
  if (monorepo) {
893
1157
  const appEntries = await loadMonorepoApps(cwd, monorepo)
894
- const allTools: McpTool[] = []
895
1158
  const allResources: McpResource[] = []
896
1159
  const allPrompts: McpPrompt[] = []
897
1160
  for (const { name, cwd: appCwd } of appEntries) {
898
1161
  const loader = createCachedAppLoader(appCwd)
899
- const ns = namespaceForApp(
900
- name,
901
- projectTools(appCwd, loader),
902
- projectFeatures(appCwd, loader),
903
- )
904
- 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
+ })
905
1168
  allResources.push(...(ns.features.resources ?? []))
906
1169
  allPrompts.push(...(ns.features.prompts ?? []))
907
1170
  }
908
- tools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
909
1171
  features = { resources: allResources, prompts: allPrompts }
910
1172
  } else {
911
1173
  const loadAppCached = createCachedAppLoader(cwd)
912
- tools = projectTools(cwd, loadAppCached)
913
- 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
+ }
914
1180
  }
1181
+ const loadAppCached = createCachedAppLoader(cwd)
915
1182
  const serverInfo = { name: "nifra", version }
916
1183
  const state = createMcpProtocolState()
917
1184
  const send = (message: JsonRpcResponse | JsonRpcNotification): void => {
918
1185
  process.stdout.write(`${JSON.stringify(message)}\n`)
919
1186
  }
920
1187
  const dispatch = async (message: JsonRpcRequest): Promise<void> => {
921
- 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, {
922
1212
  state,
923
1213
  sendNotification: send,
924
1214
  })