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.
- package/README.md +7 -4
- package/data/attribute_definitions.json +124 -74
- package/data/attribute_semantics.json +18 -1
- package/data/component_metadata.json +36 -7
- package/data/type_synonyms.json +167 -0
- package/dist/cli_runner.js +7 -1
- package/dist/index.js +4 -1
- package/dist/spec_loader.d.ts +25 -0
- package/dist/spec_loader.js +71 -0
- package/dist/tools/jui/jui_generate_converter.js +20 -5
- package/dist/tools/jui/jui_generate_project.js +11 -2
- package/dist/tools/spec/get_data_source.js +5 -2
- package/dist/tools/test/test_contracts_coverage.d.ts +10 -0
- package/dist/tools/test/test_contracts_coverage.js +95 -0
- package/dist/version.d.ts +14 -0
- package/dist/version.js +21 -0
- package/package.json +2 -1
- package/scripts/fetch-definitions.js +1 -1
- package/scripts/fetch-manifest.js +6 -0
- package/src/cli_runner.ts +7 -1
- package/src/index.ts +4 -1
- package/src/spec_loader.ts +85 -0
- package/src/tools/jui/jui_generate_converter.ts +10 -2
- package/src/tools/jui/jui_generate_project.ts +13 -2
- package/src/tools/spec/get_data_source.ts +5 -2
- package/src/tools/test/test_contracts_coverage.ts +115 -0
- package/src/version.ts +22 -0
- package/tests/cli_runner.test.ts +38 -1
- package/tests/server_version.test.ts +51 -0
- package/tests/snapshot_completeness.test.ts +1 -0
- package/tests/staleness_is_content_not_mtime.test.ts +1 -1
- package/tests/tools_cli.test.ts +43 -0
- package/tests/tools_test.test.ts +103 -5
- package/tests/type_synonyms.test.ts +178 -0
package/dist/spec_loader.js
CHANGED
|
@@ -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("
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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.
|
|
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.
|
|
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:
|
|
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
|
|
package/src/spec_loader.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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"),
|