jui-tools-mcp-server 2.8.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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]` |
@@ -1031,6 +1031,54 @@
1031
1031
  }
1032
1032
  }
1033
1033
  },
1034
+ "alert": {
1035
+ "type": "object",
1036
+ "description": "Alert configuration (iOS 15+). Same shape as confirmationDialog minus titleVisibility: .alert always shows its title, and unlike .confirmationDialog it draws the cancel role in a regular size class",
1037
+ "platform": "swift",
1038
+ "mode": "swiftui",
1039
+ "properties": {
1040
+ "isPresented": {
1041
+ "type": "binding",
1042
+ "required": true,
1043
+ "description": "Binding to control alert visibility"
1044
+ },
1045
+ "title": {
1046
+ "type": [
1047
+ "string",
1048
+ "binding"
1049
+ ],
1050
+ "default": "",
1051
+ "description": "Alert title (empty string if not specified)"
1052
+ },
1053
+ "message": {
1054
+ "type": [
1055
+ "string",
1056
+ "binding"
1057
+ ],
1058
+ "description": "Optional alert message"
1059
+ },
1060
+ "actions": {
1061
+ "type": "binding",
1062
+ "description": "Binding to function returning action buttons (alternative to layout)"
1063
+ },
1064
+ "layout": {
1065
+ "type": "object",
1066
+ "description": "Layout configuration for alert content (alternative to actions)",
1067
+ "properties": {
1068
+ "name": {
1069
+ "type": "string",
1070
+ "required": true,
1071
+ "description": "JSON filename for alert content"
1072
+ },
1073
+ "data": {
1074
+ "type": "binding",
1075
+ "required": true,
1076
+ "description": "Binding to data for the layout view"
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ },
1034
1082
  "alignment": {
1035
1083
  "type": "string",
1036
1084
  "enum": [
@@ -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.8.0",
3
+ "version": "2.10.1",
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": {
@@ -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.6.0";
47
+ const JSONUI_CLI_TAG = "v1.7.53";
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}`;
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ import { register as registerDocRulesShow } from "./tools/doc/doc_rules_show.js"
55
55
  import { register as registerTestValidate } from "./tools/test/test_validate.js";
56
56
  import { register as registerTestGenerateScreen } from "./tools/test/test_generate_screen.js";
57
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";
58
59
  import { register as registerTestGenerateDescription } from "./tools/test/test_generate_description.js";
59
60
  import { register as registerTestReport } from "./tools/test/test_report.js";
60
61
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
@@ -144,6 +145,7 @@ registerPreviewApiModelSync(server, config);
144
145
  registerTestValidate(server, config);
145
146
  registerTestGenerateScreen(server, config);
146
147
  registerTestGenerateFlow(server, config);
148
+ registerTestGenerateBranchTests(server, config);
147
149
  registerTestGenerateDescription(server, config);
148
150
  registerTestReport(server, config);
149
151
  registerTestMockGenerate(server, config);
@@ -7,7 +7,7 @@ import { runCli } from "../../cli_runner.js";
7
7
  export function register(server: McpServer, config: ServerConfig) {
8
8
  server.tool(
9
9
  "doc_validate_spec",
10
- "Validate a screen specification JSON file against the schema",
10
+ "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`.",
11
11
  {
12
12
  file: z.string().describe("Spec file path (e.g., 'login.spec.json' or full path)"),
13
13
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
@@ -0,0 +1,72 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { runCli, formatResult } from "../../cli_runner.js";
5
+
6
+ export function register(server: McpServer, config: ServerConfig) {
7
+ server.tool(
8
+ "test_generate_branch_tests",
9
+ "Generate unit-level branch tests from a screen spec's branchContracts " +
10
+ "(vitest / Kotlin JUnit4+Robolectric / Swift XCTest). Only the HTTP " +
11
+ "boundary is mocked — ViewModel, UseCase, Repository and response " +
12
+ "decoding all run for real. The generated test file and runtime are " +
13
+ "@generated; the harness skeleton is emitted once and then owned by " +
14
+ "the project. Screens with no branchContracts section are an error, " +
15
+ "not a no-op",
16
+ {
17
+ screen: z
18
+ .string()
19
+ .describe("Screen name in snake_case (resolves <spec_directory>/<screen>.spec.json)"),
20
+ platform: z
21
+ .enum(["web", "android", "ios"])
22
+ .optional()
23
+ .describe(
24
+ "Target platform (default web). android additionally requires " +
25
+ "`package`; ios additionally requires `module`"
26
+ ),
27
+ package: z
28
+ .string()
29
+ .optional()
30
+ .describe("Kotlin package for the generated sources — required for platform 'android'"),
31
+ module: z
32
+ .string()
33
+ .optional()
34
+ .describe("App module name for @testable import — required for platform 'ios'"),
35
+ spec: z
36
+ .string()
37
+ .optional()
38
+ .describe("Explicit spec file path (overrides spec_directory resolution)"),
39
+ out_dir: z
40
+ .string()
41
+ .optional()
42
+ .describe("Output directory for the @generated test + runtime files"),
43
+ harness_dir: z
44
+ .string()
45
+ .optional()
46
+ .describe("Project-owned harness directory (skeleton written only when absent)"),
47
+ mocks_dir: z
48
+ .string()
49
+ .optional()
50
+ .describe("Directory scanned for *.mock.json scenario files"),
51
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
52
+ },
53
+ async (params) => {
54
+ try {
55
+ const projectDir = config.resolveProjectDir(params.project_dir);
56
+ const args = ["generate", "branch-tests", params.screen];
57
+ if (params.platform) { args.push("--platform", params.platform); }
58
+ if (params.package) { args.push("--package", params.package); }
59
+ if (params.module) { args.push("--module", params.module); }
60
+ if (params.spec) { args.push("--spec", params.spec); }
61
+ if (params.out_dir) { args.push("--out-dir", params.out_dir); }
62
+ if (params.harness_dir) { args.push("--harness-dir", params.harness_dir); }
63
+ if (params.mocks_dir) { args.push("--mocks-dir", params.mocks_dir); }
64
+
65
+ const result = await runCli("jsonui-test", args, { cwd: projectDir });
66
+ return { content: [{ type: "text", text: formatResult(result) }] };
67
+ } catch (e: any) {
68
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
69
+ }
70
+ }
71
+ );
72
+ }
@@ -14,6 +14,7 @@ import { ServerConfig } from "../src/config.js";
14
14
  import { register as registerTestValidate } from "../src/tools/test/test_validate.js";
15
15
  import { register as registerTestGenerateScreen } from "../src/tools/test/test_generate_screen.js";
16
16
  import { register as registerTestGenerateFlow } from "../src/tools/test/test_generate_flow.js";
17
+ import { register as registerTestGenerateBranchTests } from "../src/tools/test/test_generate_branch_tests.js";
17
18
  import { register as registerTestGenerateDescription } from "../src/tools/test/test_generate_description.js";
18
19
  import { register as registerTestReport } from "../src/tools/test/test_report.js";
19
20
  import { register as registerTestMockGenerate } from "../src/tools/test/test_mock_generate.js";
@@ -65,6 +66,7 @@ beforeEach(() => {
65
66
  registerTestValidate(harness.server, config);
66
67
  registerTestGenerateScreen(harness.server, config);
67
68
  registerTestGenerateFlow(harness.server, config);
69
+ registerTestGenerateBranchTests(harness.server, config);
68
70
  registerTestGenerateDescription(harness.server, config);
69
71
  registerTestReport(harness.server, config);
70
72
  registerTestMockGenerate(harness.server, config);
@@ -86,11 +88,12 @@ function failWithCode(code: number, stdout = "", stderr = ""): void {
86
88
  }
87
89
 
88
90
  describe("Group F registration", () => {
89
- it("registers all 8 jsonui-test tools", () => {
91
+ it("registers all 9 jsonui-test tools", () => {
90
92
  expect([...harness.tools.keys()].sort()).toEqual(
91
93
  [
92
94
  "test_artifacts_pull",
93
95
  "test_artifacts_status",
96
+ "test_generate_branch_tests",
94
97
  "test_generate_description",
95
98
  "test_generate_flow",
96
99
  "test_generate_screen",
@@ -102,6 +105,68 @@ describe("Group F registration", () => {
102
105
  });
103
106
  });
104
107
 
108
+ describe("test_generate_branch_tests", () => {
109
+ it("builds the base argv and runs in the project dir", async () => {
110
+ nextResponse = { stdout: "generated 5 branch(es)" };
111
+ await harness.call("test_generate_branch_tests", {
112
+ project_dir: projectDir,
113
+ screen: "booking_confirm",
114
+ });
115
+ expect(recorded).toEqual([
116
+ expect.objectContaining({
117
+ command: "jsonui-test",
118
+ args: ["generate", "branch-tests", "booking_confirm"],
119
+ options: expect.objectContaining({ cwd: projectDir }),
120
+ }),
121
+ ]);
122
+ });
123
+
124
+ it("maps every optional flag", async () => {
125
+ nextResponse = { stdout: "ok" };
126
+ await harness.call("test_generate_branch_tests", {
127
+ project_dir: projectDir,
128
+ screen: "bottle_detail",
129
+ platform: "android",
130
+ package: "com.example.app",
131
+ module: "AppModule",
132
+ spec: "docs/screens/json/bottle_detail.spec.json",
133
+ out_dir: "app/src/test/java",
134
+ harness_dir: "app/src/test/java",
135
+ mocks_dir: "tests/mocks",
136
+ });
137
+ expect(recorded[0].args).toEqual([
138
+ "generate",
139
+ "branch-tests",
140
+ "bottle_detail",
141
+ "--platform",
142
+ "android",
143
+ "--package",
144
+ "com.example.app",
145
+ "--module",
146
+ "AppModule",
147
+ "--spec",
148
+ "docs/screens/json/bottle_detail.spec.json",
149
+ "--out-dir",
150
+ "app/src/test/java",
151
+ "--harness-dir",
152
+ "app/src/test/java",
153
+ "--mocks-dir",
154
+ "tests/mocks",
155
+ ]);
156
+ });
157
+
158
+ it("surfaces a generation failure instead of reporting success", async () => {
159
+ // Unbindable declarations are hard errors in the generator, and the
160
+ // wrapper must not flatten them into a bland OK.
161
+ failWithCode(1, "", "branches[0].when.arg.status: no parameter");
162
+ const text = await harness.call("test_generate_branch_tests", {
163
+ project_dir: projectDir,
164
+ screen: "home",
165
+ });
166
+ expect(text).toContain("no parameter");
167
+ });
168
+ });
169
+
105
170
  describe("test_validate", () => {
106
171
  it("builds the base argv and runs in the project dir", async () => {
107
172
  nextResponse = { stdout: "All valid" };