jui-tools-mcp-server 2.7.0 → 2.10.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 CHANGED
@@ -107,13 +107,14 @@ bash ~/.jsonui-mcp-server/uninstall.sh
107
107
  | `list_api_models` | 生成済み DTO/Domain 一覧(orphan 検出付き) | `jui ls api-models --json` |
108
108
  | `preview_api_model_sync` | swagger→DTO/Domain 同期の dry-run プレビュー(書き込みなし) | `jui g api --dry-run --json` |
109
109
 
110
- ### Group F: jsonui-test CLI (8)
110
+ ### Group F: jsonui-test CLI (9)
111
111
 
112
112
  | Tool | Description | CLI |
113
113
  |------|-------------|-----|
114
114
  | `test_validate` | テストファイル(screen/flow + description)のバリデーション。既定では CLI 同様に flatten-install 副作用あり(`no_install=true` で検証のみ) | `jsonui-test validate [--no-install]` |
115
115
  | `test_generate_screen` | screen テストテンプレート生成 | `jsonui-test generate test screen` |
116
116
  | `test_generate_flow` | flow テストテンプレート生成 | `jsonui-test generate test flow` |
117
+ | `test_generate_branch_tests` | spec の branchContracts から分岐テスト生成(web/android/ios) | `jsonui-test generate branch-tests` |
117
118
  | `test_generate_description` | テストケースの description JSON 生成(screen/flow) | `jsonui-test generate description` |
118
119
  | `test_report` | results JSON → JUnit XML / HTML レポート変換 | `jsonui-test report` |
119
120
  | `test_mock_generate` | OpenAPI からモック scaffold(`check=true` で drift レポートのみ・書き込みなし) | `jsonui-test mock generate [--check]` |
@@ -0,0 +1,70 @@
1
+ {
2
+ "version": 1,
3
+ "title": "Platform-scoped layout semantics",
4
+ "purpose": "How to make a node render differently (or not at all) on one platform: the node-level 'platform' directive, the layout-root 'platforms' whitelist, and how they differ from 'responsive'. Canonical implementation: jui_tools/jui_cli/core/platform_resolver.py — this file restates it for lookup and must follow it, never lead it.",
5
+ "keywords": [
6
+ "platform", "platforms", "ios only", "android only", "web only",
7
+ "per-platform", "override", "platform-specific", "platform limited",
8
+ "プラットフォーム", "限定", "出し分け", "iosだけ", "androidだけ", "webだけ",
9
+ "プラットフォーム別", "機種別"
10
+ ],
11
+ "nodeDirective": {
12
+ "attribute": "platform",
13
+ "consumedAt": "distribution (jui build). The key never appears in distributed per-platform output; platform-specific emit is produced from it.",
14
+ "objectForm": {
15
+ "summary": "Per-platform attribute overrides, merged into the node for the matching platform only. Override wins over the base value; merge is per top-level key (the override value replaces the base value for that key wholesale — it is not deep-merged into it).",
16
+ "example": {
17
+ "prompt": "Change fontColor only on iOS",
18
+ "layout": {
19
+ "type": "Label",
20
+ "text": "@{title}",
21
+ "fontColor": "#333333",
22
+ "platform": { "ios": { "fontColor": "#FFD700" } }
23
+ },
24
+ "resolved": {
25
+ "ios": { "type": "Label", "text": "@{title}", "fontColor": "#FFD700" },
26
+ "android": { "type": "Label", "text": "@{title}", "fontColor": "#333333" },
27
+ "web": { "type": "Label", "text": "@{title}", "fontColor": "#333333" }
28
+ }
29
+ },
30
+ "multiPlatformExample": {
31
+ "prompt": "Different heights everywhere, plus a web-only extra attribute",
32
+ "layout": {
33
+ "type": "View",
34
+ "height": 200,
35
+ "platform": {
36
+ "ios": { "height": 220 },
37
+ "android": { "height": 180 },
38
+ "web": { "height": "100vh", "maxWidth": 1200 }
39
+ }
40
+ }
41
+ },
42
+ "detection": "A dict counts as an override map ONLY when every key is one of 'ios' / 'android' / 'web'. Any other key (e.g. a stray 'class') makes the whole value a non-override, so nothing merges — keep override maps pure.",
43
+ "nesting": "Override values are themselves resolved recursively, so an override may contain child nodes with their own 'platform' directives."
44
+ },
45
+ "stringForm": {
46
+ "summary": "Node filter: the node (and its whole subtree) is DROPPED on platforms that do not match. Comma-separated tokens allowed.",
47
+ "example": {
48
+ "prompt": "Render this node on Android only",
49
+ "layout": { "type": "View", "platform": "android", "child": [] }
50
+ },
51
+ "tokens": {
52
+ "ios": ["ios", "swift", "swiftui", "uikit"],
53
+ "android": ["android", "kotlin", "java", "compose", "xml"],
54
+ "web": ["web", "typescript", "javascript", "react"]
55
+ }
56
+ }
57
+ },
58
+ "layoutRootPlatforms": {
59
+ "attribute": "platforms",
60
+ "summary": "Layout-root array whitelisting the distribution targets of the ENTIRE file (e.g. [\"ios\", \"android\"]). Distinct from the node-level 'platform' directive: 'platforms' picks which platforms get the file at all; 'platform' shapes a single node per platform."
61
+ },
62
+ "vsResponsive": {
63
+ "summary": "'responsive' is resolved at RUNTIME by each platform's framework (window size class / orientation); 'platform' is resolved at BUILD time and is invisible at runtime. Use 'platform' for per-OS differences, 'responsive' for per-form-factor differences. The resolver leaves 'responsive' untouched."
64
+ },
65
+ "authoringGuidance": {
66
+ "spec": "Screen specs stay platform-agnostic; when a node intentionally differs per platform, note the difference in the spec text of the section that owns the node (do not fork the spec per platform). Large screens split into sub-specs list them in the parent's 'subSpecs' array — the sub-spec that owns the node carries the note.",
67
+ "precedence": "Per-node 'platform' override > inline base attribute > 'style'-provided value. Distribution bakes the override into the node as a plain inline attribute; style merge (applied later, at codegen or dynamic runtime) always yields to inline attributes.",
68
+ "dynamicFace": "Distribution has already resolved 'platform' by the time a layout reaches a device, so the dynamic face never sees the key either — declaring it costs nothing at runtime on any face."
69
+ }
70
+ }
@@ -85,7 +85,7 @@
85
85
  }
86
86
  },
87
87
  "appOwnedScreens": {
88
- "description": "Screens the app owns outright — a hand-written page with no JsonUI layout behind it. They are real navigation destinations (a test navigates to them and asserts there), so they must be declarable, otherwise the screen-unknown rule rejects a legitimate id.",
88
+ "description": "Screens the app owns outright — a hand-written page with no JsonUI layout behind it. They are real navigation destinations (a test navigates to them and asserts there), so they must be declarable, otherwise the screen-unknown rule rejects a legitimate id. Having no layout is what they ARE, not an omission: `jui verify`'s spec-coverage check looks for a layout that generates a screen nobody declared, so these fall outside it — requiring a spec would also leave no way to comply, since there is no layout root to carry a `role` and a spec for a screen with nothing to generate describes nothing.",
89
89
  "declaration": {
90
90
  "location": "jui.config.json",
91
91
  "key": "test.appOwnedScreens",
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { register as registerGetBindingRules } from "./tools/spec/get_binding_ru
12
12
  import { register as registerGetScreenIdentity } from "./tools/spec/get_screen_identity.js";
13
13
  import { register as registerGetPlatformMapping } from "./tools/spec/get_platform_mapping.js";
14
14
  import { register as registerGetDataSource } from "./tools/spec/get_data_source.js";
15
+ import { register as registerGetPlatformRules } from "./tools/spec/get_platform_rules.js";
15
16
  // --- Group B: Project Context ---
16
17
  import { register as registerGetProjectConfig } from "./tools/context/get_project_config.js";
17
18
  import { register as registerListScreenSpecs } from "./tools/context/list_screen_specs.js";
@@ -19,6 +20,7 @@ import { register as registerListComponentSpecs } from "./tools/context/list_com
19
20
  import { register as registerListLayouts } from "./tools/context/list_layouts.js";
20
21
  import { register as registerReadSpecFile } from "./tools/context/read_spec_file.js";
21
22
  import { register as registerReadLayoutFile } from "./tools/context/read_layout_file.js";
23
+ import { register as registerSearchSpecs } from "./tools/context/search_specs.js";
22
24
  // --- Group C: jui CLI ---
23
25
  import { register as registerJuiInit } from "./tools/jui/jui_init.js";
24
26
  import { register as registerJuiGenerateProject } from "./tools/jui/jui_generate_project.js";
@@ -46,6 +48,7 @@ import { register as registerDocRulesShow } from "./tools/doc/doc_rules_show.js"
46
48
  import { register as registerTestValidate } from "./tools/test/test_validate.js";
47
49
  import { register as registerTestGenerateScreen } from "./tools/test/test_generate_screen.js";
48
50
  import { register as registerTestGenerateFlow } from "./tools/test/test_generate_flow.js";
51
+ import { register as registerTestGenerateBranchTests } from "./tools/test/test_generate_branch_tests.js";
49
52
  import { register as registerTestGenerateDescription } from "./tools/test/test_generate_description.js";
50
53
  import { register as registerTestReport } from "./tools/test/test_report.js";
51
54
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
@@ -70,7 +73,7 @@ log(`component_metadata: [${source.componentMetadata.layer}] ${source.compone
70
73
  log(`Loaded ${source.componentCount} components, ${source.commonAttributeCount} common attributes`);
71
74
  // Initialize server config for Groups B/C/D
72
75
  const config = new ServerConfig();
73
- // Register Group A: Component Spec Lookup (7 tools)
76
+ // Register Group A: Component Spec Lookup (9 tools)
74
77
  registerLookupComponent(server, loader);
75
78
  registerLookupAttribute(server, loader);
76
79
  registerSearchComponents(server, loader);
@@ -79,13 +82,15 @@ registerGetBindingRules(server, loader);
79
82
  registerGetScreenIdentity(server, loader);
80
83
  registerGetPlatformMapping(server, loader);
81
84
  registerGetDataSource(server, loader);
82
- // Register Group B: Project Context (6 tools)
85
+ registerGetPlatformRules(server, loader);
86
+ // Register Group B: Project Context (7 tools)
83
87
  registerGetProjectConfig(server, config);
84
88
  registerListScreenSpecs(server, config);
85
89
  registerListComponentSpecs(server, config);
86
90
  registerListLayouts(server, config);
87
91
  registerReadSpecFile(server, config);
88
92
  registerReadLayoutFile(server, config);
93
+ registerSearchSpecs(server, config);
89
94
  // Register Group C: jui CLI (8 tools)
90
95
  registerJuiInit(server, config);
91
96
  registerJuiGenerateProject(server, config);
@@ -116,6 +121,7 @@ registerPreviewApiModelSync(server, config);
116
121
  registerTestValidate(server, config);
117
122
  registerTestGenerateScreen(server, config);
118
123
  registerTestGenerateFlow(server, config);
124
+ registerTestGenerateBranchTests(server, config);
119
125
  registerTestGenerateDescription(server, config);
120
126
  registerTestReport(server, config);
121
127
  registerTestMockGenerate(server, config);
@@ -38,6 +38,7 @@ export interface DataSourceInfo {
38
38
  screenIdentity: FileInfo | null;
39
39
  bindingSemantics: FileInfo | null;
40
40
  attributeSemantics: FileInfo | null;
41
+ platformSemantics: FileInfo | null;
41
42
  coverage: FileInfo | null;
42
43
  componentCount: number;
43
44
  commonAttributeCount: number;
@@ -60,6 +61,7 @@ export declare class SpecLoader {
60
61
  private commonAttributesRaw;
61
62
  private bindingSemantics;
62
63
  private attributeSemantics;
64
+ private platformSemantics;
63
65
  private coverageGaps;
64
66
  private screenIdentity;
65
67
  private commonAttributes;
@@ -75,6 +77,8 @@ export declare class SpecLoader {
75
77
  getAttribute(name: string): any;
76
78
  private withImplementationGaps;
77
79
  searchComponents(query: string): any[];
80
+ /** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
81
+ private topicGuides;
78
82
  getModifierOrder(platform?: string): any;
79
83
  /**
80
84
  * The canonical screen-identity asset, or null when this checkout predates
@@ -93,6 +97,7 @@ export declare class SpecLoader {
93
97
  getChangedSinceLoad(): string[];
94
98
  getScreenIdentity(): any;
95
99
  getBindingRules(): any;
100
+ getPlatformRules(): any;
96
101
  getPlatformMapping(category?: string): any;
97
102
  listComponents(): string[];
98
103
  getDataSource(): DataSourceInfo;
@@ -35,6 +35,7 @@ export class SpecLoader {
35
35
  commonAttributesRaw = {};
36
36
  bindingSemantics = null;
37
37
  attributeSemantics = null;
38
+ platformSemantics = null;
38
39
  /// `${component}.${attribute}` -> [{platform, reason, note}] from the
39
40
  /// coverage ledger. The ledger tracks pairs that ARE declared (platform
40
41
  /// field includes them) but that the platform's codegen does not consume —
@@ -159,8 +160,40 @@ export class SpecLoader {
159
160
  }
160
161
  }
161
162
  }
163
+ // Topic-guide index: task vocabulary ("iOSだけ色を変える", "ios only",
164
+ // "platform override") does not contain any component or attribute
165
+ // name, so the loops above can't surface the mechanism. Each guide
166
+ // declares its own keywords (en + ja) in its semantics file; a hit
167
+ // pins a pointer to the dedicated tool at the top of the results.
168
+ for (const guide of this.topicGuides()) {
169
+ const hit = guide.keywords.some((kw) => q.includes(kw) || (q.length >= 3 && kw.includes(q)));
170
+ if (hit) {
171
+ results.push({
172
+ guide: guide.topic,
173
+ score: 100,
174
+ matches: [guide.summary],
175
+ nextTool: guide.tool,
176
+ });
177
+ }
178
+ }
162
179
  return results.sort((a, b) => b.score - a.score);
163
180
  }
181
+ /** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
182
+ topicGuides() {
183
+ const guides = [];
184
+ if (this.platformSemantics) {
185
+ const kws = Array.isArray(this.platformSemantics.keywords)
186
+ ? this.platformSemantics.keywords
187
+ : [];
188
+ guides.push({
189
+ topic: "platform_rules",
190
+ tool: "get_platform_rules",
191
+ summary: "Per-platform styling/visibility (e.g. change fontColor on iOS only): node-level 'platform' directive (object form = per-platform overrides, string form = platform filter), root 'platforms' whitelist, vs 'responsive'. Call get_platform_rules for the full semantics with examples.",
192
+ keywords: kws.map((k) => k.toLowerCase()),
193
+ });
194
+ }
195
+ return guides;
196
+ }
164
197
  getModifierOrder(platform) {
165
198
  if (platform)
166
199
  return MODIFIER_ORDER[platform] || null;
@@ -189,6 +222,7 @@ export class SpecLoader {
189
222
  this.dataSource.screenIdentity,
190
223
  this.dataSource.bindingSemantics,
191
224
  this.dataSource.attributeSemantics,
225
+ this.dataSource.platformSemantics,
192
226
  this.dataSource.coverage,
193
227
  ].filter((f) => f != null);
194
228
  const changed = [];
@@ -222,6 +256,14 @@ export class SpecLoader {
222
256
  semantics: this.bindingSemantics,
223
257
  };
224
258
  }
259
+ getPlatformRules() {
260
+ // Canonical platform-scoping semantics (SSoT:
261
+ // shared/core/platform_semantics.json — restates
262
+ // jui_tools/jui_cli/core/platform_resolver.py). Null on jsonui-cli
263
+ // checkouts that predate the file; the tool reports that instead of
264
+ // inventing rules.
265
+ return this.platformSemantics;
266
+ }
225
267
  getPlatformMapping(category) {
226
268
  if (category)
227
269
  return PLATFORM_MAPPING[category] || null;
@@ -262,6 +304,18 @@ export class SpecLoader {
262
304
  catch {
263
305
  this.attributeSemantics = null;
264
306
  }
307
+ // Platform-scoped layout semantics (node 'platform' directive, root
308
+ // 'platforms', vs 'responsive'). Optional for the same backward-compat
309
+ // reason.
310
+ let platformSemanticsResolution = null;
311
+ try {
312
+ const platSemResolution = this.resolveFile("shared/core/platform_semantics.json", "data/platform_semantics.json");
313
+ this.platformSemantics = JSON.parse(readFileSync(platSemResolution.path, "utf-8"));
314
+ platformSemanticsResolution = platSemResolution;
315
+ }
316
+ catch {
317
+ this.platformSemantics = null;
318
+ }
265
319
  // Canonical screen identity (screen-identity track). Optional so older
266
320
  // jsonui-cli checkouts without the file still load.
267
321
  try {
@@ -337,6 +391,7 @@ export class SpecLoader {
337
391
  screenIdentity: screenIdentityResolution,
338
392
  bindingSemantics: bindingResolution,
339
393
  attributeSemantics: attributeSemanticsResolution,
394
+ platformSemantics: platformSemanticsResolution,
340
395
  coverage: coverageResolution,
341
396
  componentCount,
342
397
  commonAttributeCount: Object.keys(this.commonAttributesRaw).filter((k) => !k.startsWith("_")).length,
@@ -13,21 +13,40 @@ export function register(server, config) {
13
13
  return { content: [{ type: "text", text: `Spec directory not found: ${specDir}` }] };
14
14
  }
15
15
  const files = readdirSync(specDir).filter((f) => f.endsWith(".spec.json"));
16
- const specs = files.map((file) => {
16
+ const specs = [];
17
+ for (const file of files) {
17
18
  try {
18
19
  const data = JSON.parse(readFileSync(join(specDir, file), "utf-8"));
19
- return {
20
+ specs.push({
20
21
  file,
21
22
  name: data.metadata?.name || file.replace(".spec.json", ""),
22
23
  displayName: data.metadata?.displayName || "",
23
24
  type: data.type || "screen_spec",
24
25
  layoutFile: data.metadata?.layoutFile || null,
25
- };
26
+ });
27
+ // A parent spec splits one screen across sub-spec files (its
28
+ // 'subSpecs' array). Surface them inline so a session can see
29
+ // the split and read_spec_file the right part without first
30
+ // opening the parent.
31
+ if (Array.isArray(data.subSpecs)) {
32
+ for (const sub of data.subSpecs) {
33
+ if (!sub?.file)
34
+ continue;
35
+ specs.push({
36
+ file: sub.file,
37
+ name: sub.name || sub.file,
38
+ displayName: sub.description || "",
39
+ type: "screen_sub_spec",
40
+ parent: file,
41
+ layoutFile: data.metadata?.layoutFile || null,
42
+ });
43
+ }
44
+ }
26
45
  }
27
46
  catch {
28
- return { file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null };
47
+ specs.push({ file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null });
29
48
  }
30
- });
49
+ }
31
50
  return { content: [{ type: "text", text: JSON.stringify(specs, null, 2) }] };
32
51
  }
33
52
  catch (e) {
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ServerConfig } from "../../config.js";
3
+ export declare function register(server: McpServer, config: ServerConfig): void;
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
3
+ import { join, relative } from "path";
4
+ const MAX_RESULTS = 30;
5
+ const SNIPPET_LEN = 200;
6
+ /** Recursively collect *.spec.json / *.component.json under dir. */
7
+ function collectSpecFiles(dir, out = []) {
8
+ for (const entry of readdirSync(dir)) {
9
+ const p = join(dir, entry);
10
+ if (statSync(p).isDirectory()) {
11
+ collectSpecFiles(p, out);
12
+ }
13
+ else if (entry.endsWith(".spec.json") || entry.endsWith(".component.json")) {
14
+ out.push(p);
15
+ }
16
+ }
17
+ return out;
18
+ }
19
+ /**
20
+ * Walk a parsed spec and report the JSON paths whose keys or string values
21
+ * contain the query. One entry per path, with a short snippet.
22
+ */
23
+ function findMatches(node, q, path, out) {
24
+ if (out.length >= MAX_RESULTS)
25
+ return;
26
+ if (typeof node === "string") {
27
+ if (node.toLowerCase().includes(q)) {
28
+ out.push({ path, snippet: node.slice(0, SNIPPET_LEN) });
29
+ }
30
+ return;
31
+ }
32
+ if (Array.isArray(node)) {
33
+ node.forEach((v, i) => findMatches(v, q, `${path}[${i}]`, out));
34
+ return;
35
+ }
36
+ if (node && typeof node === "object") {
37
+ for (const [k, v] of Object.entries(node)) {
38
+ const childPath = path ? `${path}.${k}` : k;
39
+ if (k.toLowerCase().includes(q)) {
40
+ const snippet = typeof v === "string"
41
+ ? v.slice(0, SNIPPET_LEN)
42
+ : JSON.stringify(v)?.slice(0, SNIPPET_LEN) ?? "";
43
+ out.push({ path: childPath, snippet });
44
+ if (out.length >= MAX_RESULTS)
45
+ return;
46
+ }
47
+ findMatches(v, q, childPath, out);
48
+ if (out.length >= MAX_RESULTS)
49
+ return;
50
+ }
51
+ }
52
+ }
53
+ export function register(server, config) {
54
+ server.tool("search_specs", "Keyword-search across ALL project spec files (screen specs, sub-specs in subdirectories, component specs). Returns file + JSON path + snippet per match — the fast way to find which spec (or which sub-spec of a split screen) covers a topic", {
55
+ query: z.string().describe("Search keyword (matched case-insensitively against keys and string values)"),
56
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
57
+ }, async ({ query, project_dir }) => {
58
+ try {
59
+ const projectDir = config.resolveProjectDir(project_dir);
60
+ const projectConfig = config.readProjectConfig(projectDir);
61
+ const q = query.toLowerCase();
62
+ if (!q.trim()) {
63
+ return { content: [{ type: "text", text: "Empty query." }] };
64
+ }
65
+ const dirs = new Set();
66
+ for (const field of ["spec_directory", "component_spec_directory"]) {
67
+ try {
68
+ const d = config.resolveDir(projectConfig, field, projectDir);
69
+ if (existsSync(d))
70
+ dirs.add(d);
71
+ }
72
+ catch {
73
+ /* directory not configured */
74
+ }
75
+ }
76
+ if (dirs.size === 0) {
77
+ return { content: [{ type: "text", text: "No spec directories found." }] };
78
+ }
79
+ const results = [];
80
+ for (const dir of dirs) {
81
+ for (const filePath of collectSpecFiles(dir)) {
82
+ if (results.length >= MAX_RESULTS)
83
+ break;
84
+ let matches = [];
85
+ try {
86
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
87
+ findMatches(data, q, "", matches);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (matches.length > 0) {
93
+ results.push({
94
+ file: relative(dir, filePath),
95
+ matchCount: matches.length,
96
+ matches: matches.slice(0, 5),
97
+ });
98
+ }
99
+ }
100
+ }
101
+ if (results.length === 0) {
102
+ return { content: [{ type: "text", text: `No matches for '${query}'.` }] };
103
+ }
104
+ results.sort((a, b) => b.matchCount - a.matchCount);
105
+ return {
106
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
107
+ };
108
+ }
109
+ catch (e) {
110
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
111
+ }
112
+ });
113
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { join } from "path";
3
3
  import { runCli } from "../../cli_runner.js";
4
4
  export function register(server, config) {
5
- server.tool("doc_validate_spec", "Validate a screen specification JSON file against the schema", {
5
+ server.tool("doc_validate_spec", "Validate a screen specification JSON file against the schema. Also expands `@canonical` marks in dataFlow method params/returnType against the project's OpenAPI documents, so a mark that names no operation is reported here as an error rather than reaching `jui build`.", {
6
6
  file: z.string().describe("Spec file path (e.g., 'login.spec.json' or full path)"),
7
7
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
8
8
  }, async (params) => {
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SpecLoader } from "../../spec_loader.js";
3
+ export declare function register(server: McpServer, loader: SpecLoader): void;
@@ -0,0 +1,18 @@
1
+ export function register(server, loader) {
2
+ server.tool("get_platform_rules", "Get platform-scoping rules for layouts: how to change an attribute on one platform only (node-level 'platform' object form, e.g. iOS-only fontColor), how to drop a node per platform (string form + language tokens), the layout-root 'platforms' whitelist, precedence, and how this differs from 'responsive'", {}, async () => {
3
+ const result = loader.getPlatformRules();
4
+ if (!result) {
5
+ return {
6
+ content: [
7
+ {
8
+ type: "text",
9
+ text: "platform_semantics.json not found — the connected jsonui-cli checkout predates it. Fall back to lookup_attribute('platform') for the short definition.",
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ return {
15
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
16
+ };
17
+ });
18
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ServerConfig } from "../../config.js";
3
+ export declare function register(server: McpServer, config: ServerConfig): void;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { runCli, formatResult } from "../../cli_runner.js";
3
+ export function register(server, config) {
4
+ server.tool("test_generate_branch_tests", "Generate unit-level branch tests from a screen spec's branchContracts " +
5
+ "(vitest / Kotlin JUnit4+Robolectric / Swift XCTest). Only the HTTP " +
6
+ "boundary is mocked — ViewModel, UseCase, Repository and response " +
7
+ "decoding all run for real. The generated test file and runtime are " +
8
+ "@generated; the harness skeleton is emitted once and then owned by " +
9
+ "the project. Screens with no branchContracts section are an error, " +
10
+ "not a no-op", {
11
+ screen: z
12
+ .string()
13
+ .describe("Screen name in snake_case (resolves <spec_directory>/<screen>.spec.json)"),
14
+ platform: z
15
+ .enum(["web", "android", "ios"])
16
+ .optional()
17
+ .describe("Target platform (default web). android additionally requires " +
18
+ "`package`; ios additionally requires `module`"),
19
+ package: z
20
+ .string()
21
+ .optional()
22
+ .describe("Kotlin package for the generated sources — required for platform 'android'"),
23
+ module: z
24
+ .string()
25
+ .optional()
26
+ .describe("App module name for @testable import — required for platform 'ios'"),
27
+ spec: z
28
+ .string()
29
+ .optional()
30
+ .describe("Explicit spec file path (overrides spec_directory resolution)"),
31
+ out_dir: z
32
+ .string()
33
+ .optional()
34
+ .describe("Output directory for the @generated test + runtime files"),
35
+ harness_dir: z
36
+ .string()
37
+ .optional()
38
+ .describe("Project-owned harness directory (skeleton written only when absent)"),
39
+ mocks_dir: z
40
+ .string()
41
+ .optional()
42
+ .describe("Directory scanned for *.mock.json scenario files"),
43
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
44
+ }, async (params) => {
45
+ try {
46
+ const projectDir = config.resolveProjectDir(params.project_dir);
47
+ const args = ["generate", "branch-tests", params.screen];
48
+ if (params.platform) {
49
+ args.push("--platform", params.platform);
50
+ }
51
+ if (params.package) {
52
+ args.push("--package", params.package);
53
+ }
54
+ if (params.module) {
55
+ args.push("--module", params.module);
56
+ }
57
+ if (params.spec) {
58
+ args.push("--spec", params.spec);
59
+ }
60
+ if (params.out_dir) {
61
+ args.push("--out-dir", params.out_dir);
62
+ }
63
+ if (params.harness_dir) {
64
+ args.push("--harness-dir", params.harness_dir);
65
+ }
66
+ if (params.mocks_dir) {
67
+ args.push("--mocks-dir", params.mocks_dir);
68
+ }
69
+ const result = await runCli("jsonui-test", args, { cwd: projectDir });
70
+ return { content: [{ type: "text", text: formatResult(result) }] };
71
+ }
72
+ catch (e) {
73
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
74
+ }
75
+ });
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.7.0",
3
+ "version": "2.10.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": {
@@ -33,6 +33,13 @@ export const FILES = [
33
33
  remote: "shared/core/attribute_semantics.json",
34
34
  local: "attribute_semantics.json",
35
35
  },
36
+ // Canonical platform-scoping semantics (node 'platform' directive, root
37
+ // 'platforms', vs 'responsive') — backs get_platform_rules and the
38
+ // search-side topic-guide index.
39
+ {
40
+ remote: "shared/core/platform_semantics.json",
41
+ local: "platform_semantics.json",
42
+ },
36
43
  // Canonical binding-resolution semantics. This was bundled in data/ but
37
44
  // missing from FILES, so the snapshot only ever refreshed by hand and
38
45
  // silently went stale — the same trap the screen-identity asset would
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { register as registerGetBindingRules } from "./tools/spec/get_binding_ru
14
14
  import { register as registerGetScreenIdentity } from "./tools/spec/get_screen_identity.js";
15
15
  import { register as registerGetPlatformMapping } from "./tools/spec/get_platform_mapping.js";
16
16
  import { register as registerGetDataSource } from "./tools/spec/get_data_source.js";
17
+ import { register as registerGetPlatformRules } from "./tools/spec/get_platform_rules.js";
17
18
 
18
19
  // --- Group B: Project Context ---
19
20
  import { register as registerGetProjectConfig } from "./tools/context/get_project_config.js";
@@ -22,6 +23,7 @@ import { register as registerListComponentSpecs } from "./tools/context/list_com
22
23
  import { register as registerListLayouts } from "./tools/context/list_layouts.js";
23
24
  import { register as registerReadSpecFile } from "./tools/context/read_spec_file.js";
24
25
  import { register as registerReadLayoutFile } from "./tools/context/read_layout_file.js";
26
+ import { register as registerSearchSpecs } from "./tools/context/search_specs.js";
25
27
 
26
28
  // --- Group C: jui CLI ---
27
29
  import { register as registerJuiInit } from "./tools/jui/jui_init.js";
@@ -53,6 +55,7 @@ import { register as registerDocRulesShow } from "./tools/doc/doc_rules_show.js"
53
55
  import { register as registerTestValidate } from "./tools/test/test_validate.js";
54
56
  import { register as registerTestGenerateScreen } from "./tools/test/test_generate_screen.js";
55
57
  import { register as registerTestGenerateFlow } from "./tools/test/test_generate_flow.js";
58
+ import { register as registerTestGenerateBranchTests } from "./tools/test/test_generate_branch_tests.js";
56
59
  import { register as registerTestGenerateDescription } from "./tools/test/test_generate_description.js";
57
60
  import { register as registerTestReport } from "./tools/test/test_report.js";
58
61
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
@@ -89,7 +92,7 @@ log(
89
92
  // Initialize server config for Groups B/C/D
90
93
  const config = new ServerConfig();
91
94
 
92
- // Register Group A: Component Spec Lookup (7 tools)
95
+ // Register Group A: Component Spec Lookup (9 tools)
93
96
  registerLookupComponent(server, loader);
94
97
  registerLookupAttribute(server, loader);
95
98
  registerSearchComponents(server, loader);
@@ -98,14 +101,16 @@ registerGetBindingRules(server, loader);
98
101
  registerGetScreenIdentity(server, loader);
99
102
  registerGetPlatformMapping(server, loader);
100
103
  registerGetDataSource(server, loader);
104
+ registerGetPlatformRules(server, loader);
101
105
 
102
- // Register Group B: Project Context (6 tools)
106
+ // Register Group B: Project Context (7 tools)
103
107
  registerGetProjectConfig(server, config);
104
108
  registerListScreenSpecs(server, config);
105
109
  registerListComponentSpecs(server, config);
106
110
  registerListLayouts(server, config);
107
111
  registerReadSpecFile(server, config);
108
112
  registerReadLayoutFile(server, config);
113
+ registerSearchSpecs(server, config);
109
114
 
110
115
  // Register Group C: jui CLI (8 tools)
111
116
  registerJuiInit(server, config);
@@ -140,6 +145,7 @@ registerPreviewApiModelSync(server, config);
140
145
  registerTestValidate(server, config);
141
146
  registerTestGenerateScreen(server, config);
142
147
  registerTestGenerateFlow(server, config);
148
+ registerTestGenerateBranchTests(server, config);
143
149
  registerTestGenerateDescription(server, config);
144
150
  registerTestReport(server, config);
145
151
  registerTestMockGenerate(server, config);