jui-tools-mcp-server 2.12.10 → 2.14.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.
@@ -59,6 +59,10 @@ export class SpecLoader {
59
59
  screenIdentity = null;
60
60
  commonAttributes = null;
61
61
  aliasMap = new Map();
62
+ /// Lower-cased spelling -> its entry in type_synonyms.json, for the
63
+ /// spellings that resolve through it (not a section, `_alias_of` or
64
+ /// metadata alias of their own).
65
+ typeSynonyms = new Map();
62
66
  metadata = {};
63
67
  dataSource;
64
68
  /** When this process read the files. Data is cached in memory from then
@@ -123,6 +127,13 @@ export class SpecLoader {
123
127
  const comp = this.getComponent(name);
124
128
  if (!comp)
125
129
  return null;
130
+ // Say which spelling was asked for and why it resolved: the build
131
+ // accepts `ProgressBar` as a Progress, and an HStack is a View that
132
+ // also means `orientation: horizontal`.
133
+ const synonym = this.typeSynonyms.get(name.toLowerCase());
134
+ if (synonym) {
135
+ return { ...comp, typeSynonym: synonym, commonAttributes: this.commonAttributes };
136
+ }
126
137
  return { ...comp, commonAttributes: this.commonAttributes };
127
138
  }
128
139
  getAttribute(name) {
@@ -202,6 +213,15 @@ export class SpecLoader {
202
213
  matches.push(`rule: ${rule}`);
203
214
  }
204
215
  }
216
+ // A spelling the build accepts for this type (type_synonyms.json).
217
+ for (const synonym of this.typeSynonyms.values()) {
218
+ if (this.getComponent(synonym.spelling) !== comp)
219
+ continue;
220
+ if (synonym.spelling.toLowerCase().includes(q)) {
221
+ score += 8;
222
+ matches.push(`type synonym: ${synonym.spelling}`);
223
+ }
224
+ }
205
225
  if (score > 0)
206
226
  results.push({ component: comp.name, score, matches });
207
227
  }
@@ -290,6 +310,7 @@ export class SpecLoader {
290
310
  this.dataSource.attributeSemantics,
291
311
  this.dataSource.platformSemantics,
292
312
  this.dataSource.coverage,
313
+ this.dataSource.typeSynonyms,
293
314
  ].filter((f) => f != null);
294
315
  const changed = [];
295
316
  for (const file of tracked) {
@@ -458,6 +479,7 @@ export class SpecLoader {
458
479
  }
459
480
  componentCount++;
460
481
  }
482
+ const typeSynonymsResolution = this.loadTypeSynonyms();
461
483
  this.dataSource = {
462
484
  attributeDefinitions: attrResolution,
463
485
  componentMetadata: metaResolution,
@@ -466,10 +488,59 @@ export class SpecLoader {
466
488
  attributeSemantics: attributeSemanticsResolution,
467
489
  platformSemantics: platformSemanticsResolution,
468
490
  coverage: coverageResolution,
491
+ typeSynonyms: typeSynonymsResolution,
469
492
  componentCount,
470
493
  commonAttributeCount: Object.keys(this.commonAttributesRaw).filter((k) => !k.startsWith("_")).length,
471
494
  };
472
495
  }
496
+ /**
497
+ * Type spellings the build accepts for a section they are not
498
+ * (shared/core/type_synonyms.json, jsonui-cli 1.9.0 — the one table the
499
+ * tools and the dynamic runtimes read). Until then lookup_component knew
500
+ * only `_alias_of` sections and metadata aliases, and answered "not found"
501
+ * for `ProgressBar`, which the build draws as a Progress.
502
+ *
503
+ * A section, an `_alias_of` section or a metadata alias of the same
504
+ * spelling wins: the exact match is found first, as in the tools. An entry
505
+ * whose canonical is not a section here is skipped.
506
+ *
507
+ * Absent (older jsonui-cli): no synonyms. Present but not JSON: the load
508
+ * throws, so an automatic reload keeps the last content that parsed rather
509
+ * than swapping in a loader that silently lost every synonym.
510
+ */
511
+ loadTypeSynonyms() {
512
+ this.typeSynonyms = new Map();
513
+ let resolution;
514
+ try {
515
+ resolution = this.resolveFile("shared/core/type_synonyms.json", "data/type_synonyms.json");
516
+ }
517
+ catch {
518
+ return null;
519
+ }
520
+ const doc = JSON.parse(readFileSync(resolution.path, "utf-8"));
521
+ for (const [spelling, entry] of Object.entries(doc.synonyms ?? {})) {
522
+ if (typeof entry !== "object" || entry === null)
523
+ continue;
524
+ const { canonical, render_as: renderAs, ...implies } = entry;
525
+ if (typeof canonical !== "string" || canonical.length === 0)
526
+ continue;
527
+ const key = spelling.toLowerCase();
528
+ if (this.components.has(key) || this.aliasMap.has(key))
529
+ continue;
530
+ const target = this.aliasMap.get(canonical.toLowerCase()) ?? canonical.toLowerCase();
531
+ if (!this.components.has(target))
532
+ continue;
533
+ this.aliasMap.set(key, target);
534
+ this.typeSynonyms.set(key, {
535
+ spelling,
536
+ canonical,
537
+ ...(typeof renderAs === "string" ? { renderAs } : {}),
538
+ ...(Object.keys(implies).length > 0 ? { implies } : {}),
539
+ source: "shared/core/type_synonyms.json",
540
+ });
541
+ }
542
+ return resolution;
543
+ }
473
544
  normalizeMetadata(raw) {
474
545
  return {
475
546
  description: raw.description ?? "",
@@ -6,10 +6,17 @@ export function register(server, config) {
6
6
  from_spec: z.string().optional().describe("Generate from component spec file path"),
7
7
  all: z.boolean().optional().describe("Generate from all component specs"),
8
8
  attributes: z.string().optional().describe("Attributes in key:type format (e.g., 'prop1:String,prop2:Int')"),
9
- container: z.boolean().optional().describe("Mark as container component"),
10
- skip_existing: z.boolean().optional().describe("Leave existing converter files untouched (no overwrite prompt). " +
11
- "`jui build` enables this automatically; pass it here for idempotent " +
12
- "explicit scaffold runs."),
9
+ container: z.boolean().optional().describe("true: a container (it draws its children). false: a leaf (it takes no " +
10
+ "children, and a layout that gives it some is refused by name) — needs " +
11
+ "jsonui-cli 1.9.0 or later (earlier `jui` rejects --no-container). " +
12
+ "Absent: the default form."),
13
+ skip_existing: z.boolean().optional().describe("Leave existing converter and scaffold files untouched, without the " +
14
+ "overwrite prompt — for idempotent scaffold runs. Wins over `force` " +
15
+ "when both are set."),
16
+ force: z.boolean().optional().describe("Replace existing converter and scaffold files without the overwrite " +
17
+ "prompt. The scaffolds are the project's own code: edits made in them " +
18
+ "are lost. Needs jsonui-cli 1.8.113 or later (earlier `jui` rejects " +
19
+ "--force)."),
13
20
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
14
21
  }, async (params) => {
15
22
  try {
@@ -27,12 +34,20 @@ export function register(server, config) {
27
34
  if (params.attributes) {
28
35
  args.push("--attributes", params.attributes);
29
36
  }
30
- if (params.container) {
37
+ // `false` asks for a leaf. It used to pass nothing, so the command ran
38
+ // in the default form and the request was dropped without a word.
39
+ if (params.container === true) {
31
40
  args.push("--container");
32
41
  }
42
+ if (params.container === false) {
43
+ args.push("--no-container");
44
+ }
33
45
  if (params.skip_existing) {
34
46
  args.push("--skip-existing");
35
47
  }
48
+ if (params.force) {
49
+ args.push("--force");
50
+ }
36
51
  const result = await runCli("jui", args, { cwd: projectDir });
37
52
  return { content: [{ type: "text", text: formatResult(result) }] };
38
53
  }
@@ -1,9 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { runCli, formatResult } from "../../cli_runner.js";
3
3
  export function register(server, config) {
4
- server.tool("jui_generate_project", "Generate Layout JSON and ViewModel files from screen specs (all or single spec)", {
4
+ server.tool("jui_generate_project", "Generate Layout JSON and ViewModel files from screen specs (all or single spec). " +
5
+ "A Layout JSON it generates — a screen's, or a Collection cell / header / footer's — is " +
6
+ "written when it is not there, left as it is when it already holds exactly what the spec " +
7
+ "generates, and otherwise KEPT (the Layout JSON is hand-edited after generation) and named " +
8
+ "in the output with \"Kept existing … layout: <file> … --force replaces it\". From " +
9
+ "jsonui-cli 1.9.0; an earlier jui rewrote an existing layout on every run.", {
5
10
  spec_file: z.string().optional().describe("Single spec file to process (e.g., 'login.spec.json')"),
6
- force: z.boolean().optional().describe("Force overwrite declaration files"),
11
+ force: z.boolean().optional().describe("Replace the ViewModel declaration files, and each Layout JSON that already exists and " +
12
+ "differs from what the spec generates (kept and named without it). A layout whose data " +
13
+ "section holds entries the spec does not declare is not replaced even with force — the " +
14
+ "output names them. Called from here the command has no terminal, so it never asks: " +
15
+ "without force an existing layout is kept."),
7
16
  skip_layout: z.boolean().optional().describe("Skip Layout JSON generation"),
8
17
  dry_run: z.boolean().optional().describe("Show what would be generated without writing"),
9
18
  platform: z.enum(["ios", "android", "web"]).optional().describe("Generate for single platform only"),
@@ -1,7 +1,8 @@
1
1
  export function register(server, loader) {
2
- server.tool("get_data_source", "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.", {}, async () => {
2
+ server.tool("get_data_source", "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json, binding_semantics.json and the rest, down to type_synonyms.json (the type spellings the build accepts). Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: since 2.12.0 every tool call re-reads a file whose CONTENT changed (sha256, not mtime), so a restart is not needed after a jsonui-cli distribution; `lastReload` says when that last happened and whether it succeeded, and `staleInMemory` is non-empty only when a reload FAILED (a file did not parse) and the server is still serving the previous content.", {}, async () => {
3
3
  const info = loader.getDataSource();
4
- // 🚨 THE SAME SEVEN THE STALENESS CHECK WATCHES. This list held five
4
+ // 🚨 THE SAME FILES THE STALENESS CHECK WATCHES (eight since
5
+ // type_synonyms.json). This list held five
5
6
  // while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
6
7
  // name a path that appeared in no per-file entry — and a reader who
7
8
  // "checked every file reported" had two outside their view. Reported by
@@ -15,6 +16,7 @@ export function register(server, loader) {
15
16
  info.attributeSemantics,
16
17
  info.platformSemantics,
17
18
  info.coverage,
19
+ info.typeSynonyms,
18
20
  ].filter((f) => f != null);
19
21
  const stale = files.some((f) => f.freshness === "stale");
20
22
  // The data is read once at construction and cached in memory, so a
@@ -35,6 +37,7 @@ export function register(server, loader) {
35
37
  attributeSemantics: info.attributeSemantics,
36
38
  platformSemantics: info.platformSemantics,
37
39
  coverage: info.coverage,
40
+ typeSynonyms: info.typeSynonyms,
38
41
  componentCount: info.componentCount,
39
42
  commonAttributeCount: info.commonAttributeCount,
40
43
  loadedAt,
@@ -0,0 +1,10 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ServerConfig } from "../../config.js";
3
+ import { CliResult } from "../../cli_runner.js";
4
+ export declare function register(server: McpServer, config: ServerConfig): void;
5
+ /**
6
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
7
+ * Otherwise an envelope that says which of the two is missing — with `exit`
8
+ * set only where the CLI's own meaning applies (2: it could not start).
9
+ */
10
+ export declare function coverageEnvelope(result: CliResult): string;
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { runCli } from "../../cli_runner.js";
3
+ // The CLI decides the verdict; this tool only carries it. Its exit is the
4
+ // verdict itself — 0 pass (or empty), 1 uncovered or a declaration error,
5
+ // 2 cannot start, 3 something could not be evaluated — so a failure of the
6
+ // TOOL must never be reported as one of those numbers. A timeout reported as
7
+ // exit 1 would read as "uncovered".
8
+ const MIN_CLI = "1.8.116";
9
+ export function register(server, config) {
10
+ server.tool("test_contracts_coverage", "For every screen and platform, bucket every response status the OpenAPI " +
11
+ "declares for an operation the screen's dataFlow names: answered by a " +
12
+ "branch row, excluded with a reason (unit / unreachable / unexpressible), " +
13
+ "not evaluated, or uncovered (partial / default-only / unattributed). " +
14
+ "Endpoints listed only in dataFlow.apiEndpoints are named as unbound. " +
15
+ "Read-only, and not a gate yet. Returns the CLI's JSON report; its `exit` " +
16
+ "is the verdict: 0 pass (or empty), 1 uncovered or a declaration error, " +
17
+ "2 cannot start, 3 something could not be evaluated and nothing is " +
18
+ `uncovered. Needs jsonui-cli ${MIN_CLI} or later`, {
19
+ screen: z
20
+ .string()
21
+ .optional()
22
+ .describe("One screen (the spec's snake_case name); every screen when omitted"),
23
+ platform: z
24
+ .array(z.enum(["web", "android", "ios"]))
25
+ .optional()
26
+ .describe("Platforms to evaluate (default: the platforms jui.config.json declares)"),
27
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
28
+ }, async (params) => {
29
+ try {
30
+ const projectDir = config.resolveProjectDir(params.project_dir);
31
+ const args = ["contracts", "coverage"];
32
+ if (params.screen) {
33
+ args.push(params.screen);
34
+ }
35
+ for (const p of params.platform ?? []) {
36
+ args.push("--platform", p);
37
+ }
38
+ args.push("--json");
39
+ const result = await runCli("jsonui-test", args, { cwd: projectDir, timeout: 180_000 });
40
+ return { content: [{ type: "text", text: coverageEnvelope(result) }] };
41
+ }
42
+ catch (e) {
43
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
44
+ }
45
+ });
46
+ }
47
+ /**
48
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
49
+ * Otherwise an envelope that says which of the two is missing — with `exit`
50
+ * set only where the CLI's own meaning applies (2: it could not start).
51
+ */
52
+ export function coverageEnvelope(result) {
53
+ const text = result.stdout.trim();
54
+ let report;
55
+ try {
56
+ report = JSON.parse(text);
57
+ }
58
+ catch {
59
+ report = undefined;
60
+ }
61
+ if (report && typeof report === "object" && typeof report.exit === "number") {
62
+ if (report.exit === result.exitCode) {
63
+ return text;
64
+ }
65
+ return JSON.stringify({
66
+ exit: null,
67
+ verdict: "no_report",
68
+ error: `the report says exit ${report.exit} but the process exited ${String(result.exitCode)}`,
69
+ report,
70
+ }, null, 2);
71
+ }
72
+ const errors = result.stderr || undefined;
73
+ if (result.exitCode === "ENOENT") {
74
+ return JSON.stringify({
75
+ exit: 2,
76
+ verdict: "cannot_start",
77
+ error: `jsonui-test is not on PATH — install jsonui-cli ${MIN_CLI} or later`,
78
+ }, null, 2);
79
+ }
80
+ if (result.exitCode === 2 && /invalid choice: 'contracts'/.test(result.stderr)) {
81
+ return JSON.stringify({
82
+ exit: 2,
83
+ verdict: "cannot_start",
84
+ error: `this jsonui-test has no \`contracts coverage\` — it arrived in jsonui-cli ${MIN_CLI}`,
85
+ errors,
86
+ }, null, 2);
87
+ }
88
+ return JSON.stringify({
89
+ exit: null,
90
+ verdict: "no_report",
91
+ error: `no JSON report (process exit ${String(result.exitCode)})`,
92
+ output: result.stdout || undefined,
93
+ errors,
94
+ }, null, 2);
95
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The version this server reports to MCP clients, read from package.json.
3
+ *
4
+ * The server used to hand McpServer a literal "2.0.0" — written in the
5
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
6
+ * told clients it was 2.0.0. A literal beside a version that is bumped
7
+ * elsewhere drifts silently; reading the one file the release bumps does
8
+ * not.
9
+ *
10
+ * `../package.json` resolves from both places this module runs: src/ under
11
+ * the test runner and dist/ at runtime (npm package and deploy checkout
12
+ * alike keep package.json at the root, one level up).
13
+ */
14
+ export declare function packageVersion(moduleUrl?: string): string;
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "fs";
2
+ /**
3
+ * The version this server reports to MCP clients, read from package.json.
4
+ *
5
+ * The server used to hand McpServer a literal "2.0.0" — written in the
6
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
7
+ * told clients it was 2.0.0. A literal beside a version that is bumped
8
+ * elsewhere drifts silently; reading the one file the release bumps does
9
+ * not.
10
+ *
11
+ * `../package.json` resolves from both places this module runs: src/ under
12
+ * the test runner and dist/ at runtime (npm package and deploy checkout
13
+ * alike keep package.json at the root, one level up).
14
+ */
15
+ export function packageVersion(moduleUrl = import.meta.url) {
16
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", moduleUrl), "utf8"));
17
+ if (typeof pkg.version !== "string" || pkg.version === "") {
18
+ throw new Error("package.json has no version");
19
+ }
20
+ return pkg.version;
21
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.12.10",
3
+ "version": "2.14.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc",
8
+ "prepack": "npm run build",
8
9
  "start": "node dist/index.js",
9
10
  "test": "vitest run",
10
11
  "test:watch": "vitest",
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
44
44
  // 3. `npm run fetch-definitions` and commit the data/ diff together with
45
45
  // this pin in one commit
46
46
  // JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
47
- const JSONUI_CLI_TAG = "v1.8.110";
47
+ const JSONUI_CLI_TAG = "v1.9.0";
48
48
  const BASE_URL =
49
49
  process.env.JSONUI_CLI_RAW_BASE ||
50
50
  `https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;
@@ -59,6 +59,12 @@ export const FILES = [
59
59
  // Conformance coverage ledger (declared-but-unimplemented gaps). The loader
60
60
  // reads data/coverage.json as its fallback, so it must refresh with the rest
61
61
  // — binding_semantics.json already fell into this exact stale-snapshot trap.
62
+ // Type-spelling synonyms (jsonui-cli 1.9.0): lookup_component and
63
+ // search_components resolve ProgressBar, HStack, Text … through it.
64
+ {
65
+ remote: "shared/core/type_synonyms.json",
66
+ local: "type_synonyms.json",
67
+ },
62
68
  {
63
69
  remote: "conformance/coverage.json",
64
70
  local: "coverage.json",
package/src/cli_runner.ts CHANGED
@@ -14,7 +14,7 @@ export async function runCli(
14
14
  const timeout = options.timeout ?? 60_000;
15
15
 
16
16
  return new Promise((resolve) => {
17
- execFile(
17
+ const child = execFile(
18
18
  command,
19
19
  args,
20
20
  {
@@ -39,6 +39,12 @@ export async function runCli(
39
39
  });
40
40
  }
41
41
  );
42
+ // Nothing is ever written to the child's stdin: end it now, so a CLI that
43
+ // reads stdin — an overwrite prompt — sees end-of-file instead of waiting
44
+ // for a line that never comes. Through 2.13.1 the pipe stayed open, and such
45
+ // a child waited until the timeout above killed it (`jui g converter` on
46
+ // an existing component, measured 2026-09-26).
47
+ child.stdin?.end();
42
48
  });
43
49
  }
44
50
 
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { SpecLoader, mcpRootFromImportMetaUrl } from "./spec_loader.js";
6
6
  import { installAutoReload } from "./auto_reload.js";
7
7
  import { ServerConfig } from "./config.js";
8
+ import { packageVersion } from "./version.js";
8
9
 
9
10
  // --- Group A: Component Spec Lookup ---
10
11
  import { register as registerLookupComponent } from "./tools/spec/lookup_component.js";
@@ -62,6 +63,7 @@ import { register as registerTestReport } from "./tools/test/test_report.js";
62
63
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
63
64
  import { register as registerTestArtifactsPull } from "./tools/test/test_artifacts_pull.js";
64
65
  import { register as registerTestArtifactsStatus } from "./tools/test/test_artifacts_status.js";
66
+ import { register as registerTestContractsCoverage } from "./tools/test/test_contracts_coverage.js";
65
67
 
66
68
  function log(message: string) {
67
69
  console.error(`[jui-tools] ${new Date().toISOString()} ${message}`);
@@ -71,7 +73,7 @@ log("Server starting...");
71
73
 
72
74
  const server = new McpServer({
73
75
  name: "jui-tools",
74
- version: "2.0.0",
76
+ version: packageVersion(),
75
77
  });
76
78
 
77
79
  // Initialize spec loader for Group A
@@ -155,6 +157,7 @@ registerTestReport(server, config);
155
157
  registerTestMockGenerate(server, config);
156
158
  registerTestArtifactsPull(server, config);
157
159
  registerTestArtifactsStatus(server, config);
160
+ registerTestContractsCoverage(server, config);
158
161
 
159
162
  log(`Registered 41 tools (7 spec + 6 context + 8 jui + 9 doc + 3 api + 8 test)`);
160
163
 
@@ -90,6 +90,17 @@ export interface ReloadRecord {
90
90
  error?: string;
91
91
  }
92
92
 
93
+ /// A spelling the build accepts for a type it is not, as type_synonyms.json
94
+ /// says: its canonical section, the type it is drawn as when that differs,
95
+ /// and the attributes the spelling itself means.
96
+ export interface TypeSynonym {
97
+ spelling: string;
98
+ canonical: string;
99
+ renderAs?: string;
100
+ implies?: Record<string, unknown>;
101
+ source: string;
102
+ }
103
+
93
104
  export interface DataSourceInfo {
94
105
  attributeDefinitions: FileInfo;
95
106
  componentMetadata: FileInfo;
@@ -111,6 +122,10 @@ export interface DataSourceInfo {
111
122
  /// attribute, platform) pairs with their recorded reasons. Optional: absent
112
123
  /// on older jsonui-cli checkouts.
113
124
  coverage: FileInfo | null;
125
+ /// shared/core/type_synonyms.json — type spellings the build accepts for a
126
+ /// section they are not (ProgressBar, HStack, Text …). Optional: absent on
127
+ /// jsonui-cli before 1.9.0.
128
+ typeSynonyms: FileInfo | null;
114
129
  componentCount: number;
115
130
  commonAttributeCount: number;
116
131
  }
@@ -157,6 +172,10 @@ export class SpecLoader {
157
172
  private screenIdentity: Record<string, any> | null = null;
158
173
  private commonAttributes: any = null;
159
174
  private aliasMap: Map<string, string> = new Map();
175
+ /// Lower-cased spelling -> its entry in type_synonyms.json, for the
176
+ /// spellings that resolve through it (not a section, `_alias_of` or
177
+ /// metadata alias of their own).
178
+ private typeSynonyms: Map<string, TypeSynonym> = new Map();
160
179
  private metadata: Record<string, ComponentMetadata> = {};
161
180
  private dataSource!: DataSourceInfo;
162
181
  /** When this process read the files. Data is cached in memory from then
@@ -223,6 +242,13 @@ export class SpecLoader {
223
242
  getComponentWithCommon(name: string): any {
224
243
  const comp = this.getComponent(name);
225
244
  if (!comp) return null;
245
+ // Say which spelling was asked for and why it resolved: the build
246
+ // accepts `ProgressBar` as a Progress, and an HStack is a View that
247
+ // also means `orientation: horizontal`.
248
+ const synonym = this.typeSynonyms.get(name.toLowerCase());
249
+ if (synonym) {
250
+ return { ...comp, typeSynonym: synonym, commonAttributes: this.commonAttributes };
251
+ }
226
252
  return { ...comp, commonAttributes: this.commonAttributes };
227
253
  }
228
254
 
@@ -312,6 +338,15 @@ export class SpecLoader {
312
338
  }
313
339
  }
314
340
 
341
+ // A spelling the build accepts for this type (type_synonyms.json).
342
+ for (const synonym of this.typeSynonyms.values()) {
343
+ if (this.getComponent(synonym.spelling) !== comp) continue;
344
+ if (synonym.spelling.toLowerCase().includes(q)) {
345
+ score += 8;
346
+ matches.push(`type synonym: ${synonym.spelling}`);
347
+ }
348
+ }
349
+
315
350
  if (score > 0) results.push({ component: comp.name, score, matches });
316
351
  }
317
352
 
@@ -422,6 +457,7 @@ export class SpecLoader {
422
457
  this.dataSource.attributeSemantics,
423
458
  this.dataSource.platformSemantics,
424
459
  this.dataSource.coverage,
460
+ this.dataSource.typeSynonyms,
425
461
  ].filter((f): f is FileInfo => f != null);
426
462
 
427
463
  const changed: string[] = [];
@@ -635,6 +671,8 @@ export class SpecLoader {
635
671
  componentCount++;
636
672
  }
637
673
 
674
+ const typeSynonymsResolution = this.loadTypeSynonyms();
675
+
638
676
  this.dataSource = {
639
677
  attributeDefinitions: attrResolution,
640
678
  componentMetadata: metaResolution,
@@ -643,6 +681,7 @@ export class SpecLoader {
643
681
  attributeSemantics: attributeSemanticsResolution,
644
682
  platformSemantics: platformSemanticsResolution,
645
683
  coverage: coverageResolution,
684
+ typeSynonyms: typeSynonymsResolution,
646
685
  componentCount,
647
686
  commonAttributeCount: Object.keys(this.commonAttributesRaw).filter(
648
687
  (k) => !k.startsWith("_")
@@ -650,6 +689,52 @@ export class SpecLoader {
650
689
  };
651
690
  }
652
691
 
692
+ /**
693
+ * Type spellings the build accepts for a section they are not
694
+ * (shared/core/type_synonyms.json, jsonui-cli 1.9.0 — the one table the
695
+ * tools and the dynamic runtimes read). Until then lookup_component knew
696
+ * only `_alias_of` sections and metadata aliases, and answered "not found"
697
+ * for `ProgressBar`, which the build draws as a Progress.
698
+ *
699
+ * A section, an `_alias_of` section or a metadata alias of the same
700
+ * spelling wins: the exact match is found first, as in the tools. An entry
701
+ * whose canonical is not a section here is skipped.
702
+ *
703
+ * Absent (older jsonui-cli): no synonyms. Present but not JSON: the load
704
+ * throws, so an automatic reload keeps the last content that parsed rather
705
+ * than swapping in a loader that silently lost every synonym.
706
+ */
707
+ private loadTypeSynonyms(): FileInfo | null {
708
+ this.typeSynonyms = new Map();
709
+ let resolution: FileInfo;
710
+ try {
711
+ resolution = this.resolveFile("shared/core/type_synonyms.json", "data/type_synonyms.json");
712
+ } catch {
713
+ return null;
714
+ }
715
+ const doc = JSON.parse(readFileSync(resolution.path, "utf-8")) as {
716
+ synonyms?: Record<string, Record<string, unknown>>;
717
+ };
718
+ for (const [spelling, entry] of Object.entries(doc.synonyms ?? {})) {
719
+ if (typeof entry !== "object" || entry === null) continue;
720
+ const { canonical, render_as: renderAs, ...implies } = entry;
721
+ if (typeof canonical !== "string" || canonical.length === 0) continue;
722
+ const key = spelling.toLowerCase();
723
+ if (this.components.has(key) || this.aliasMap.has(key)) continue;
724
+ const target = this.aliasMap.get(canonical.toLowerCase()) ?? canonical.toLowerCase();
725
+ if (!this.components.has(target)) continue;
726
+ this.aliasMap.set(key, target);
727
+ this.typeSynonyms.set(key, {
728
+ spelling,
729
+ canonical,
730
+ ...(typeof renderAs === "string" ? { renderAs } : {}),
731
+ ...(Object.keys(implies).length > 0 ? { implies } : {}),
732
+ source: "shared/core/type_synonyms.json",
733
+ });
734
+ }
735
+ return resolution;
736
+ }
737
+
653
738
  private normalizeMetadata(raw: Record<string, any>): ComponentMetadata {
654
739
  return {
655
740
  description: raw.description ?? "",
@@ -12,7 +12,12 @@ export function register(server: McpServer, config: ServerConfig) {
12
12
  from_spec: z.string().optional().describe("Generate from component spec file path"),
13
13
  all: z.boolean().optional().describe("Generate from all component specs"),
14
14
  attributes: z.string().optional().describe("Attributes in key:type format (e.g., 'prop1:String,prop2:Int')"),
15
- container: z.boolean().optional().describe("Mark as container component"),
15
+ container: z.boolean().optional().describe(
16
+ "true: a container (it draws its children). false: a leaf (it takes no " +
17
+ "children, and a layout that gives it some is refused by name) — needs " +
18
+ "jsonui-cli 1.9.0 or later (earlier `jui` rejects --no-container). " +
19
+ "Absent: the default form."
20
+ ),
16
21
  skip_existing: z.boolean().optional().describe(
17
22
  "Leave existing converter and scaffold files untouched, without the " +
18
23
  "overwrite prompt — for idempotent scaffold runs. Wins over `force` " +
@@ -34,7 +39,10 @@ export function register(server: McpServer, config: ServerConfig) {
34
39
  if (params.from_spec) { args.push("--from", params.from_spec); }
35
40
  if (params.all) { args.push("--all"); }
36
41
  if (params.attributes) { args.push("--attributes", params.attributes); }
37
- if (params.container) { args.push("--container"); }
42
+ // `false` asks for a leaf. It used to pass nothing, so the command ran
43
+ // in the default form and the request was dropped without a word.
44
+ if (params.container === true) { args.push("--container"); }
45
+ if (params.container === false) { args.push("--no-container"); }
38
46
  if (params.skip_existing) { args.push("--skip-existing"); }
39
47
  if (params.force) { args.push("--force"); }
40
48
 
@@ -6,10 +6,21 @@ import { runCli, formatResult } from "../../cli_runner.js";
6
6
  export function register(server: McpServer, config: ServerConfig) {
7
7
  server.tool(
8
8
  "jui_generate_project",
9
- "Generate Layout JSON and ViewModel files from screen specs (all or single spec)",
9
+ "Generate Layout JSON and ViewModel files from screen specs (all or single spec). " +
10
+ "A Layout JSON it generates — a screen's, or a Collection cell / header / footer's — is " +
11
+ "written when it is not there, left as it is when it already holds exactly what the spec " +
12
+ "generates, and otherwise KEPT (the Layout JSON is hand-edited after generation) and named " +
13
+ "in the output with \"Kept existing … layout: <file> … --force replaces it\". From " +
14
+ "jsonui-cli 1.9.0; an earlier jui rewrote an existing layout on every run.",
10
15
  {
11
16
  spec_file: z.string().optional().describe("Single spec file to process (e.g., 'login.spec.json')"),
12
- force: z.boolean().optional().describe("Force overwrite declaration files"),
17
+ force: z.boolean().optional().describe(
18
+ "Replace the ViewModel declaration files, and each Layout JSON that already exists and " +
19
+ "differs from what the spec generates (kept and named without it). A layout whose data " +
20
+ "section holds entries the spec does not declare is not replaced even with force — the " +
21
+ "output names them. Called from here the command has no terminal, so it never asks: " +
22
+ "without force an existing layout is kept."
23
+ ),
13
24
  skip_layout: z.boolean().optional().describe("Skip Layout JSON generation"),
14
25
  dry_run: z.boolean().optional().describe("Show what would be generated without writing"),
15
26
  platform: z.enum(["ios", "android", "web"]).optional().describe("Generate for single platform only"),