jui-tools-mcp-server 2.12.9 → 2.13.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
@@ -46,9 +46,9 @@ bash ~/.jsonui-mcp-server/install.sh
46
46
  bash ~/.jsonui-mcp-server/uninstall.sh
47
47
  ```
48
48
 
49
- ## Available Tools (42)
49
+ ## Available Tools (46)
50
50
 
51
- ### Group A: コンポーネント仕様参照 (8)
51
+ ### Group A: コンポーネント仕様参照 (9)
52
52
 
53
53
  | Tool | Description |
54
54
  |------|-------------|
@@ -60,8 +60,9 @@ bash ~/.jsonui-mcp-server/uninstall.sh
60
60
  | `get_platform_mapping` | プラットフォーム間の値変換マッピング |
61
61
  | `get_screen_identity` | スクリーン正準定義(screen とは何か・id 導出・表示判定述語) |
62
62
  | `get_data_source` | データ由来(layer / path / mtime / 鮮度)の確認 |
63
+ | `get_platform_rules` | Layout の platform 指定の規則(1 platform だけ属性を変える node の `platform` オブジェクト、platform ごとに node を落とす文字列形、root の `platforms`、優先順位、`responsive` との違い) |
63
64
 
64
- ### Group B: プロジェクトコンテキスト (6)
65
+ ### Group B: プロジェクトコンテキスト (7)
65
66
 
66
67
  | Tool | Description |
67
68
  |------|-------------|
@@ -71,6 +72,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
71
72
  | `list_layouts` | Layout JSON ファイル一覧 |
72
73
  | `read_spec_file` | spec ファイルの内容を返す |
73
74
  | `read_layout_file` | Layout JSON の内容を返す |
75
+ | `search_specs` | 全 spec(画面・サブディレクトリの sub-spec・コンポーネント)をキーワード検索し、ファイル・JSON path・抜粋を返す |
74
76
 
75
77
  ### Group C: jui CLI (8)
76
78
 
@@ -107,7 +109,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
107
109
  | `list_api_models` | 生成済み DTO/Domain 一覧(orphan 検出付き) | `jui ls api-models --json` |
108
110
  | `preview_api_model_sync` | swagger→DTO/Domain 同期の dry-run プレビュー(書き込みなし) | `jui g api --dry-run --json` |
109
111
 
110
- ### Group F: jsonui-test CLI (9)
112
+ ### Group F: jsonui-test CLI (10)
111
113
 
112
114
  | Tool | Description | CLI |
113
115
  |------|-------------|-----|
@@ -120,6 +122,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
120
122
  | `test_mock_generate` | OpenAPI からモック scaffold(`check=true` で drift レポートのみ・書き込みなし) | `jsonui-test mock generate [--check]` |
121
123
  | `test_artifacts_pull` | 最新実行のテスト成果物(スクリーンショット/録画)を iOS xcresult / Android 実機から artifacts ディレクトリへ取り込み | `jsonui-test artifacts pull --json` |
122
124
  | `test_artifacts_status` | test-artifacts 設定の解決結果(出力先、iOS xcresult 探索、Android appId)と取り込み済みファイル一覧 | `jsonui-test artifacts status --json` |
125
+ | `test_contracts_coverage` | OpenAPI が宣言する応答 status を、画面・VM method・operation・platform ごとに「行が答える / 理由つきの除外 / 評価できない / 不足」に振り分ける(読み取り専用、まだ門ではない。exit 0 pass・1 不足か宣言の誤り・2 始められない・3 評価できないものがある。jsonui-cli 1.8.116 以上) | `jsonui-test contracts coverage [screen] [--platform …] --json` |
123
126
 
124
127
  > `jsonui-test mock serve`(常駐 HTTP + run-target 実行)は RCE 面のため **MCP 非公開**(CLI 直叩き限定)。
125
128
 
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { SpecLoader, mcpRootFromImportMetaUrl } from "./spec_loader.js";
5
5
  import { installAutoReload } from "./auto_reload.js";
6
6
  import { ServerConfig } from "./config.js";
7
+ import { packageVersion } from "./version.js";
7
8
  // --- Group A: Component Spec Lookup ---
8
9
  import { register as registerLookupComponent } from "./tools/spec/lookup_component.js";
9
10
  import { register as registerLookupAttribute } from "./tools/spec/lookup_attribute.js";
@@ -55,13 +56,14 @@ import { register as registerTestReport } from "./tools/test/test_report.js";
55
56
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
56
57
  import { register as registerTestArtifactsPull } from "./tools/test/test_artifacts_pull.js";
57
58
  import { register as registerTestArtifactsStatus } from "./tools/test/test_artifacts_status.js";
59
+ import { register as registerTestContractsCoverage } from "./tools/test/test_contracts_coverage.js";
58
60
  function log(message) {
59
61
  console.error(`[jui-tools] ${new Date().toISOString()} ${message}`);
60
62
  }
61
63
  log("Server starting...");
62
64
  const server = new McpServer({
63
65
  name: "jui-tools",
64
- version: "2.0.0",
66
+ version: packageVersion(),
65
67
  });
66
68
  // Initialize spec loader for Group A
67
69
  // Resolves attribute_definitions.json via 4-layer fallback:
@@ -131,6 +133,7 @@ registerTestReport(server, config);
131
133
  registerTestMockGenerate(server, config);
132
134
  registerTestArtifactsPull(server, config);
133
135
  registerTestArtifactsStatus(server, config);
136
+ registerTestContractsCoverage(server, config);
134
137
  log(`Registered 41 tools (7 spec + 6 context + 8 jui + 9 doc + 3 api + 8 test)`);
135
138
  async function main() {
136
139
  const transport = new StdioServerTransport();
@@ -7,9 +7,13 @@ export function register(server, config) {
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
9
  container: z.boolean().optional().describe("Mark as container component"),
10
- skip_existing: z.boolean().optional().describe("Leave existing converter files untouched (no overwrite prompt). " +
11
- "`jui build` enables this automatically; pass it here for idempotent " +
12
- "explicit scaffold runs."),
10
+ skip_existing: z.boolean().optional().describe("Leave existing converter and scaffold files untouched, without the " +
11
+ "overwrite prompt — for idempotent scaffold runs. Wins over `force` " +
12
+ "when both are set."),
13
+ force: z.boolean().optional().describe("Replace existing converter and scaffold files without the overwrite " +
14
+ "prompt. The scaffolds are the project's own code: edits made in them " +
15
+ "are lost. Needs jsonui-cli 1.8.113 or later (earlier `jui` rejects " +
16
+ "--force)."),
13
17
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
14
18
  }, async (params) => {
15
19
  try {
@@ -33,6 +37,9 @@ export function register(server, config) {
33
37
  if (params.skip_existing) {
34
38
  args.push("--skip-existing");
35
39
  }
40
+ if (params.force) {
41
+ args.push("--force");
42
+ }
36
43
  const result = await runCli("jui", args, { cwd: projectDir });
37
44
  return { content: [{ type: "text", text: formatResult(result) }] };
38
45
  }
@@ -0,0 +1,10 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ServerConfig } from "../../config.js";
3
+ import { CliResult } from "../../cli_runner.js";
4
+ export declare function register(server: McpServer, config: ServerConfig): void;
5
+ /**
6
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
7
+ * Otherwise an envelope that says which of the two is missing — with `exit`
8
+ * set only where the CLI's own meaning applies (2: it could not start).
9
+ */
10
+ export declare function coverageEnvelope(result: CliResult): string;
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { runCli } from "../../cli_runner.js";
3
+ // The CLI decides the verdict; this tool only carries it. Its exit is the
4
+ // verdict itself — 0 pass (or empty), 1 uncovered or a declaration error,
5
+ // 2 cannot start, 3 something could not be evaluated — so a failure of the
6
+ // TOOL must never be reported as one of those numbers. A timeout reported as
7
+ // exit 1 would read as "uncovered".
8
+ const MIN_CLI = "1.8.116";
9
+ export function register(server, config) {
10
+ server.tool("test_contracts_coverage", "For every screen and platform, bucket every response status the OpenAPI " +
11
+ "declares for an operation the screen's dataFlow names: answered by a " +
12
+ "branch row, excluded with a reason (unit / unreachable / unexpressible), " +
13
+ "not evaluated, or uncovered (partial / default-only / unattributed). " +
14
+ "Endpoints listed only in dataFlow.apiEndpoints are named as unbound. " +
15
+ "Read-only, and not a gate yet. Returns the CLI's JSON report; its `exit` " +
16
+ "is the verdict: 0 pass (or empty), 1 uncovered or a declaration error, " +
17
+ "2 cannot start, 3 something could not be evaluated and nothing is " +
18
+ `uncovered. Needs jsonui-cli ${MIN_CLI} or later`, {
19
+ screen: z
20
+ .string()
21
+ .optional()
22
+ .describe("One screen (the spec's snake_case name); every screen when omitted"),
23
+ platform: z
24
+ .array(z.enum(["web", "android", "ios"]))
25
+ .optional()
26
+ .describe("Platforms to evaluate (default: the platforms jui.config.json declares)"),
27
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
28
+ }, async (params) => {
29
+ try {
30
+ const projectDir = config.resolveProjectDir(params.project_dir);
31
+ const args = ["contracts", "coverage"];
32
+ if (params.screen) {
33
+ args.push(params.screen);
34
+ }
35
+ for (const p of params.platform ?? []) {
36
+ args.push("--platform", p);
37
+ }
38
+ args.push("--json");
39
+ const result = await runCli("jsonui-test", args, { cwd: projectDir, timeout: 180_000 });
40
+ return { content: [{ type: "text", text: coverageEnvelope(result) }] };
41
+ }
42
+ catch (e) {
43
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
44
+ }
45
+ });
46
+ }
47
+ /**
48
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
49
+ * Otherwise an envelope that says which of the two is missing — with `exit`
50
+ * set only where the CLI's own meaning applies (2: it could not start).
51
+ */
52
+ export function coverageEnvelope(result) {
53
+ const text = result.stdout.trim();
54
+ let report;
55
+ try {
56
+ report = JSON.parse(text);
57
+ }
58
+ catch {
59
+ report = undefined;
60
+ }
61
+ if (report && typeof report === "object" && typeof report.exit === "number") {
62
+ if (report.exit === result.exitCode) {
63
+ return text;
64
+ }
65
+ return JSON.stringify({
66
+ exit: null,
67
+ verdict: "no_report",
68
+ error: `the report says exit ${report.exit} but the process exited ${String(result.exitCode)}`,
69
+ report,
70
+ }, null, 2);
71
+ }
72
+ const errors = result.stderr || undefined;
73
+ if (result.exitCode === "ENOENT") {
74
+ return JSON.stringify({
75
+ exit: 2,
76
+ verdict: "cannot_start",
77
+ error: `jsonui-test is not on PATH — install jsonui-cli ${MIN_CLI} or later`,
78
+ }, null, 2);
79
+ }
80
+ if (result.exitCode === 2 && /invalid choice: 'contracts'/.test(result.stderr)) {
81
+ return JSON.stringify({
82
+ exit: 2,
83
+ verdict: "cannot_start",
84
+ error: `this jsonui-test has no \`contracts coverage\` — it arrived in jsonui-cli ${MIN_CLI}`,
85
+ errors,
86
+ }, null, 2);
87
+ }
88
+ return JSON.stringify({
89
+ exit: null,
90
+ verdict: "no_report",
91
+ error: `no JSON report (process exit ${String(result.exitCode)})`,
92
+ output: result.stdout || undefined,
93
+ errors,
94
+ }, null, 2);
95
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The version this server reports to MCP clients, read from package.json.
3
+ *
4
+ * The server used to hand McpServer a literal "2.0.0" — written in the
5
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
6
+ * told clients it was 2.0.0. A literal beside a version that is bumped
7
+ * elsewhere drifts silently; reading the one file the release bumps does
8
+ * not.
9
+ *
10
+ * `../package.json` resolves from both places this module runs: src/ under
11
+ * the test runner and dist/ at runtime (npm package and deploy checkout
12
+ * alike keep package.json at the root, one level up).
13
+ */
14
+ export declare function packageVersion(moduleUrl?: string): string;
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from "fs";
2
+ /**
3
+ * The version this server reports to MCP clients, read from package.json.
4
+ *
5
+ * The server used to hand McpServer a literal "2.0.0" — written in the
6
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
7
+ * told clients it was 2.0.0. A literal beside a version that is bumped
8
+ * elsewhere drifts silently; reading the one file the release bumps does
9
+ * not.
10
+ *
11
+ * `../package.json` resolves from both places this module runs: src/ under
12
+ * the test runner and dist/ at runtime (npm package and deploy checkout
13
+ * alike keep package.json at the root, one level up).
14
+ */
15
+ export function packageVersion(moduleUrl = import.meta.url) {
16
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", moduleUrl), "utf8"));
17
+ if (typeof pkg.version !== "string" || pkg.version === "") {
18
+ throw new Error("package.json has no version");
19
+ }
20
+ return pkg.version;
21
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.12.9",
3
+ "version": "2.13.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",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { SpecLoader, mcpRootFromImportMetaUrl } from "./spec_loader.js";
6
6
  import { installAutoReload } from "./auto_reload.js";
7
7
  import { ServerConfig } from "./config.js";
8
+ import { packageVersion } from "./version.js";
8
9
 
9
10
  // --- Group A: Component Spec Lookup ---
10
11
  import { register as registerLookupComponent } from "./tools/spec/lookup_component.js";
@@ -62,6 +63,7 @@ import { register as registerTestReport } from "./tools/test/test_report.js";
62
63
  import { register as registerTestMockGenerate } from "./tools/test/test_mock_generate.js";
63
64
  import { register as registerTestArtifactsPull } from "./tools/test/test_artifacts_pull.js";
64
65
  import { register as registerTestArtifactsStatus } from "./tools/test/test_artifacts_status.js";
66
+ import { register as registerTestContractsCoverage } from "./tools/test/test_contracts_coverage.js";
65
67
 
66
68
  function log(message: string) {
67
69
  console.error(`[jui-tools] ${new Date().toISOString()} ${message}`);
@@ -71,7 +73,7 @@ log("Server starting...");
71
73
 
72
74
  const server = new McpServer({
73
75
  name: "jui-tools",
74
- version: "2.0.0",
76
+ version: packageVersion(),
75
77
  });
76
78
 
77
79
  // Initialize spec loader for Group A
@@ -155,6 +157,7 @@ registerTestReport(server, config);
155
157
  registerTestMockGenerate(server, config);
156
158
  registerTestArtifactsPull(server, config);
157
159
  registerTestArtifactsStatus(server, config);
160
+ registerTestContractsCoverage(server, config);
158
161
 
159
162
  log(`Registered 41 tools (7 spec + 6 context + 8 jui + 9 doc + 3 api + 8 test)`);
160
163
 
@@ -14,9 +14,15 @@ export function register(server: McpServer, config: ServerConfig) {
14
14
  attributes: z.string().optional().describe("Attributes in key:type format (e.g., 'prop1:String,prop2:Int')"),
15
15
  container: z.boolean().optional().describe("Mark as container component"),
16
16
  skip_existing: z.boolean().optional().describe(
17
- "Leave existing converter files untouched (no overwrite prompt). " +
18
- "`jui build` enables this automatically; pass it here for idempotent " +
19
- "explicit scaffold runs."
17
+ "Leave existing converter and scaffold files untouched, without the " +
18
+ "overwrite prompt — for idempotent scaffold runs. Wins over `force` " +
19
+ "when both are set."
20
+ ),
21
+ force: z.boolean().optional().describe(
22
+ "Replace existing converter and scaffold files without the overwrite " +
23
+ "prompt. The scaffolds are the project's own code: edits made in them " +
24
+ "are lost. Needs jsonui-cli 1.8.113 or later (earlier `jui` rejects " +
25
+ "--force)."
20
26
  ),
21
27
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
22
28
  },
@@ -30,6 +36,7 @@ export function register(server: McpServer, config: ServerConfig) {
30
36
  if (params.attributes) { args.push("--attributes", params.attributes); }
31
37
  if (params.container) { args.push("--container"); }
32
38
  if (params.skip_existing) { args.push("--skip-existing"); }
39
+ if (params.force) { args.push("--force"); }
33
40
 
34
41
  const result = await runCli("jui", args, { cwd: projectDir });
35
42
  return { content: [{ type: "text", text: formatResult(result) }] };
@@ -0,0 +1,115 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { ServerConfig } from "../../config.js";
4
+ import { runCli, CliResult } from "../../cli_runner.js";
5
+
6
+ // The CLI decides the verdict; this tool only carries it. Its exit is the
7
+ // verdict itself — 0 pass (or empty), 1 uncovered or a declaration error,
8
+ // 2 cannot start, 3 something could not be evaluated — so a failure of the
9
+ // TOOL must never be reported as one of those numbers. A timeout reported as
10
+ // exit 1 would read as "uncovered".
11
+ const MIN_CLI = "1.8.116";
12
+
13
+ export function register(server: McpServer, config: ServerConfig) {
14
+ server.tool(
15
+ "test_contracts_coverage",
16
+ "For every screen and platform, bucket every response status the OpenAPI " +
17
+ "declares for an operation the screen's dataFlow names: answered by a " +
18
+ "branch row, excluded with a reason (unit / unreachable / unexpressible), " +
19
+ "not evaluated, or uncovered (partial / default-only / unattributed). " +
20
+ "Endpoints listed only in dataFlow.apiEndpoints are named as unbound. " +
21
+ "Read-only, and not a gate yet. Returns the CLI's JSON report; its `exit` " +
22
+ "is the verdict: 0 pass (or empty), 1 uncovered or a declaration error, " +
23
+ "2 cannot start, 3 something could not be evaluated and nothing is " +
24
+ `uncovered. Needs jsonui-cli ${MIN_CLI} or later`,
25
+ {
26
+ screen: z
27
+ .string()
28
+ .optional()
29
+ .describe("One screen (the spec's snake_case name); every screen when omitted"),
30
+ platform: z
31
+ .array(z.enum(["web", "android", "ios"]))
32
+ .optional()
33
+ .describe("Platforms to evaluate (default: the platforms jui.config.json declares)"),
34
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
35
+ },
36
+ async (params) => {
37
+ try {
38
+ const projectDir = config.resolveProjectDir(params.project_dir);
39
+ const args = ["contracts", "coverage"];
40
+ if (params.screen) { args.push(params.screen); }
41
+ for (const p of params.platform ?? []) { args.push("--platform", p); }
42
+ args.push("--json");
43
+ const result = await runCli("jsonui-test", args, { cwd: projectDir, timeout: 180_000 });
44
+ return { content: [{ type: "text", text: coverageEnvelope(result) }] };
45
+ } catch (e: any) {
46
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
47
+ }
48
+ }
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The CLI's JSON verbatim when there is one and its `exit` is the process's.
54
+ * Otherwise an envelope that says which of the two is missing — with `exit`
55
+ * set only where the CLI's own meaning applies (2: it could not start).
56
+ */
57
+ export function coverageEnvelope(result: CliResult): string {
58
+ const text = result.stdout.trim();
59
+ let report: any;
60
+ try {
61
+ report = JSON.parse(text);
62
+ } catch {
63
+ report = undefined;
64
+ }
65
+ if (report && typeof report === "object" && typeof report.exit === "number") {
66
+ if (report.exit === result.exitCode) {
67
+ return text;
68
+ }
69
+ return JSON.stringify(
70
+ {
71
+ exit: null,
72
+ verdict: "no_report",
73
+ error: `the report says exit ${report.exit} but the process exited ${String(result.exitCode)}`,
74
+ report,
75
+ },
76
+ null,
77
+ 2
78
+ );
79
+ }
80
+ const errors = result.stderr || undefined;
81
+ if (result.exitCode === ("ENOENT" as any)) {
82
+ return JSON.stringify(
83
+ {
84
+ exit: 2,
85
+ verdict: "cannot_start",
86
+ error: `jsonui-test is not on PATH — install jsonui-cli ${MIN_CLI} or later`,
87
+ },
88
+ null,
89
+ 2
90
+ );
91
+ }
92
+ if (result.exitCode === 2 && /invalid choice: 'contracts'/.test(result.stderr)) {
93
+ return JSON.stringify(
94
+ {
95
+ exit: 2,
96
+ verdict: "cannot_start",
97
+ error: `this jsonui-test has no \`contracts coverage\` — it arrived in jsonui-cli ${MIN_CLI}`,
98
+ errors,
99
+ },
100
+ null,
101
+ 2
102
+ );
103
+ }
104
+ return JSON.stringify(
105
+ {
106
+ exit: null,
107
+ verdict: "no_report",
108
+ error: `no JSON report (process exit ${String(result.exitCode)})`,
109
+ output: result.stdout || undefined,
110
+ errors,
111
+ },
112
+ null,
113
+ 2
114
+ );
115
+ }
package/src/version.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "fs";
2
+
3
+ /**
4
+ * The version this server reports to MCP clients, read from package.json.
5
+ *
6
+ * The server used to hand McpServer a literal "2.0.0" — written in the
7
+ * initial commit and never moved, so every release from 2.0.0 to 2.12.x
8
+ * told clients it was 2.0.0. A literal beside a version that is bumped
9
+ * elsewhere drifts silently; reading the one file the release bumps does
10
+ * not.
11
+ *
12
+ * `../package.json` resolves from both places this module runs: src/ under
13
+ * the test runner and dist/ at runtime (npm package and deploy checkout
14
+ * alike keep package.json at the root, one level up).
15
+ */
16
+ export function packageVersion(moduleUrl: string = import.meta.url): string {
17
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", moduleUrl), "utf8"));
18
+ if (typeof pkg.version !== "string" || pkg.version === "") {
19
+ throw new Error("package.json has no version");
20
+ }
21
+ return pkg.version;
22
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The server reports package.json's version, not a literal.
3
+ *
4
+ * Until 2.13.0 the McpServer was built with version "2.0.0" (a literal from
5
+ * the initial commit), so every release told MCP clients it was 2.0.0.
6
+ */
7
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ import { pathToFileURL } from "url";
11
+ import { afterEach, describe, expect, it } from "vitest";
12
+ import { packageVersion } from "../src/version.js";
13
+
14
+ const repoRoot = join(__dirname, "..");
15
+ const tmpDirs: string[] = [];
16
+
17
+ function fakeModuleUrl(pkg: unknown): string {
18
+ // A root with package.json and a src/ one level down, like the real layout.
19
+ const root = mkdtempSync(join(tmpdir(), "jui-version-"));
20
+ tmpDirs.push(root);
21
+ mkdirSync(join(root, "src"));
22
+ writeFileSync(join(root, "package.json"), JSON.stringify(pkg));
23
+ return pathToFileURL(join(root, "src", "version.js")).href;
24
+ }
25
+
26
+ afterEach(() => {
27
+ while (tmpDirs.length) rmSync(tmpDirs.pop()!, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("server version", () => {
31
+ it("is the repository's package.json version", () => {
32
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
33
+ expect(packageVersion()).toBe(pkg.version);
34
+ });
35
+
36
+ it("follows whatever package.json says (a literal could not)", () => {
37
+ expect(packageVersion(fakeModuleUrl({ version: "9.8.7" }))).toBe("9.8.7");
38
+ expect(packageVersion(fakeModuleUrl({ version: "0.0.1-rc.2" }))).toBe("0.0.1-rc.2");
39
+ });
40
+
41
+ it("refuses a package.json without a version", () => {
42
+ expect(() => packageVersion(fakeModuleUrl({ name: "x" }))).toThrow(/no version/);
43
+ expect(() => packageVersion(fakeModuleUrl({ version: "" }))).toThrow(/no version/);
44
+ });
45
+
46
+ it("is what index.ts hands McpServer — no version literal left there", () => {
47
+ const src = readFileSync(join(repoRoot, "src", "index.ts"), "utf8");
48
+ expect(src).toMatch(/new McpServer\(\{[^}]*version:\s*packageVersion\(\)/s);
49
+ expect(src).not.toMatch(/version:\s*["'`]\d+\.\d+\.\d+/);
50
+ });
51
+ });
@@ -284,6 +284,28 @@ describe("jui_generate_converter", () => {
284
284
  "--all",
285
285
  ]);
286
286
  });
287
+
288
+ // `jui g converter --force` exists from jsonui-cli 1.8.113; the tool had no
289
+ // way to pass it.
290
+ it("passes --force only when asked", async () => {
291
+ await harness.call("jui_generate_converter", {
292
+ all: true,
293
+ force: true,
294
+ project_dir: projectDir,
295
+ });
296
+ await harness.call("jui_generate_converter", { all: true, project_dir: projectDir });
297
+ expect(recorded[0].args).toEqual(["generate", "converter", "--all", "--force"]);
298
+ expect(recorded[1].args).toEqual(["generate", "converter", "--all"]);
299
+ });
300
+
301
+ // The description said "`jui build` enables this automatically": the build
302
+ // has not generated converters for a long time (jsonui-cli 1.8.113 removed
303
+ // the same sentence from its own docstring).
304
+ it("does not tell the agent that jui build skips existing converters", () => {
305
+ const schema = harness.tools.get("jui_generate_converter")!.schema;
306
+ expect(schema.skip_existing.description).not.toContain("jui build");
307
+ expect(schema.force.description).toContain("1.8.113");
308
+ });
287
309
  });
288
310
 
289
311
  describe("jui_verify", () => {
@@ -20,6 +20,7 @@ import { register as registerTestReport } from "../src/tools/test/test_report.js
20
20
  import { register as registerTestMockGenerate } from "../src/tools/test/test_mock_generate.js";
21
21
  import { register as registerTestArtifactsPull } from "../src/tools/test/test_artifacts_pull.js";
22
22
  import { register as registerTestArtifactsStatus } from "../src/tools/test/test_artifacts_status.js";
23
+ import { register as registerTestContractsCoverage } from "../src/tools/test/test_contracts_coverage.js";
23
24
 
24
25
  import {
25
26
  cleanupTempDirs,
@@ -72,6 +73,7 @@ beforeEach(() => {
72
73
  registerTestMockGenerate(harness.server, config);
73
74
  registerTestArtifactsPull(harness.server, config);
74
75
  registerTestArtifactsStatus(harness.server, config);
76
+ registerTestContractsCoverage(harness.server, config);
75
77
  });
76
78
 
77
79
  afterEach(() => {
@@ -88,11 +90,12 @@ function failWithCode(code: number, stdout = "", stderr = ""): void {
88
90
  }
89
91
 
90
92
  describe("Group F registration", () => {
91
- it("registers all 9 jsonui-test tools", () => {
93
+ it("registers all 10 jsonui-test tools", () => {
92
94
  expect([...harness.tools.keys()].sort()).toEqual(
93
95
  [
94
96
  "test_artifacts_pull",
95
97
  "test_artifacts_status",
98
+ "test_contracts_coverage",
96
99
  "test_generate_branch_tests",
97
100
  "test_generate_description",
98
101
  "test_generate_flow",
@@ -347,3 +350,98 @@ describe("test_artifacts_status", () => {
347
350
  expect(execFileMock).not.toHaveBeenCalled();
348
351
  });
349
352
  });
353
+
354
+ describe("test_contracts_coverage", () => {
355
+ const report = (exit: number) =>
356
+ JSON.stringify({ app: { spec_file: null, rules: 0, declaration_errors: [] }, platforms: [], exit }, null, 2);
357
+
358
+ it("runs `jsonui-test contracts coverage --json` in the project dir", async () => {
359
+ nextResponse = { stdout: report(0) };
360
+ await harness.call("test_contracts_coverage", { project_dir: projectDir });
361
+ expect(recorded).toEqual([
362
+ expect.objectContaining({
363
+ command: "jsonui-test",
364
+ args: ["contracts", "coverage", "--json"],
365
+ options: expect.objectContaining({ cwd: projectDir, timeout: 180_000 }),
366
+ }),
367
+ ]);
368
+ });
369
+
370
+ it("maps the screen and every platform, in order", async () => {
371
+ nextResponse = { stdout: report(0) };
372
+ await harness.call("test_contracts_coverage", {
373
+ project_dir: projectDir,
374
+ screen: "detail",
375
+ platform: ["web", "ios"],
376
+ });
377
+ expect(recorded[0].args).toEqual([
378
+ "contracts", "coverage", "detail", "--platform", "web", "--platform", "ios", "--json",
379
+ ]);
380
+ });
381
+
382
+ it("resolves jsonui-test the way the other jsonui-test tools do", async () => {
383
+ // The same bare command name and the same environment as
384
+ // test_artifacts_status: the installed jsonui-cli on PATH, never a
385
+ // development tree put in front of it by this tool.
386
+ nextResponse = { stdout: "{}" };
387
+ await harness.call("test_artifacts_status", { project_dir: projectDir });
388
+ nextResponse = { stdout: report(0) };
389
+ await harness.call("test_contracts_coverage", { project_dir: projectDir });
390
+ const [sibling, coverage] = recorded;
391
+ expect(coverage.command).toBe(sibling.command);
392
+ expect(coverage.options.env).toEqual(sibling.options.env);
393
+ expect(coverage.options.env.PYTHONPATH).toBe(process.env.PYTHONPATH);
394
+ expect(coverage.options.env.JSONUI_CLI_PATH).toBeUndefined();
395
+ });
396
+
397
+ // The exit IS the verdict, so each of the four has to arrive as itself —
398
+ // not flattened into success/failure the way formatResult would.
399
+ it.each([0, 1, 2, 3])("passes the report through verbatim when it and the process both say exit %i", async (code) => {
400
+ if (code === 0) {
401
+ nextResponse = { stdout: report(0) };
402
+ } else {
403
+ failWithCode(code, report(code));
404
+ }
405
+ const text = await harness.call("test_contracts_coverage", { project_dir: projectDir });
406
+ expect(text).toBe(report(code));
407
+ expect(JSON.parse(text).exit).toBe(code);
408
+ });
409
+
410
+ it("says so when the report and the process disagree about the exit", async () => {
411
+ failWithCode(1, report(0));
412
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
413
+ expect(parsed.exit).toBeNull();
414
+ expect(parsed.verdict).toBe("no_report");
415
+ expect(parsed.error).toContain("exit 0");
416
+ expect(parsed.report.exit).toBe(0);
417
+ });
418
+
419
+ it("names the version when the installed jsonui-test has no contracts command", async () => {
420
+ failWithCode(2, "", "jsonui-test: error: argument command: invalid choice: 'contracts'");
421
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
422
+ expect(parsed).toMatchObject({ exit: 2, verdict: "cannot_start" });
423
+ expect(parsed.error).toContain("1.8.116");
424
+ });
425
+
426
+ it("says jsonui-test is missing when it is not on PATH", async () => {
427
+ nextResponse = { error: Object.assign(new Error("spawn jsonui-test ENOENT"), { code: "ENOENT" }) };
428
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
429
+ expect(parsed).toMatchObject({ exit: 2, verdict: "cannot_start" });
430
+ expect(parsed.error).toContain("not on PATH");
431
+ });
432
+
433
+ it("does not report a timeout as a coverage verdict", async () => {
434
+ // runCli turns a timeout into exit 1 — which is "uncovered" here.
435
+ nextResponse = { error: Object.assign(new Error("killed"), { killed: true, code: null }), stdout: '{"plat' };
436
+ const parsed = JSON.parse(await harness.call("test_contracts_coverage", { project_dir: projectDir }));
437
+ expect(parsed.exit).toBeNull();
438
+ expect(parsed.verdict).toBe("no_report");
439
+ expect(parsed.errors).toContain("timed out");
440
+ });
441
+
442
+ it("does not spawn anything when no project dir is configured", async () => {
443
+ const text = await harness.call("test_contracts_coverage");
444
+ expect(text).toMatch(/^Error: /);
445
+ expect(execFileMock).not.toHaveBeenCalled();
446
+ });
447
+ });