@emseepea/testing 0.14.1 → 0.15.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
@@ -163,6 +163,38 @@ Keep the capture version, release policy, baseline location, and retention in
163
163
  the application repository. Run capture only for a contract that has actually
164
164
  been published or submitted for review.
165
165
 
166
+ For the same comparison without a custom wrapper, add project-local commands:
167
+
168
+ ```json
169
+ {
170
+ "scripts": {
171
+ "contract:capture": "emseepea-contract capture --factory ./dist/app.js --factory-export createApp",
172
+ "contract:check": "emseepea-contract check --factory ./dist/app.js --factory-export createApp --baselines contract-baselines"
173
+ }
174
+ }
175
+ ```
176
+
177
+ Capture a version only when your application policy says it is published:
178
+
179
+ ```sh
180
+ npm run contract:capture -- --version 1.0.0 --baseline contract-baselines/1.0.0.json
181
+ npm run contract:check
182
+ ```
183
+
184
+ Use `--url https://example.com/mcp` instead of `--factory` to inspect an MCP
185
+ endpoint. For protected discovery, set the token in an environment variable
186
+ and pass its name with `--token-env`; the token value is not accepted as a
187
+ command argument or written to output or baselines. Use `--protocol-version`
188
+ when the server requires a supported older protocol revision.
189
+
190
+ Capture exits 0 after writing the requested baseline. Check exits 0 when every
191
+ JSON baseline in the directory is compatible, 1 for compatibility failures,
192
+ and 2 for invalid options, extraction failures, or invalid baseline files.
193
+
194
+ Custom normalization, legacy migration, stricter comparison rules, baseline
195
+ retention, approvals, and deployment policy stay in application code using the
196
+ lower-level API above.
197
+
166
198
  ## Check Static Color Contrast
167
199
 
168
200
  Use `wcagContrastRatio` to measure one opaque solid sRGB color pair. Use
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,10 +30,12 @@
30
30
  }
31
31
  },
32
32
  "bin": {
33
+ "emseepea-contract": "./published-contract/cli.mjs",
33
34
  "emseepea-test": "./semantic/cli.mjs"
34
35
  },
35
36
  "files": [
36
37
  "dist",
38
+ "published-contract",
37
39
  "semantic",
38
40
  "LICENSE",
39
41
  "README.md"
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7
+
8
+ import {
9
+ assertPublishedMcpContractCompatible,
10
+ extractPublishedMcpContract,
11
+ startEmseepea,
12
+ writePublishedMcpContractBaseline,
13
+ } from "../dist/index.js";
14
+
15
+ const protocolVersions = new Set([
16
+ "2026-07-28",
17
+ "2025-11-25",
18
+ "2025-06-18",
19
+ "2025-03-26",
20
+ "2024-11-05",
21
+ "2024-10-07",
22
+ ]);
23
+
24
+ class CommandError extends Error {
25
+ constructor(exitCode, text) {
26
+ super(text);
27
+ this.exitCode = exitCode;
28
+ }
29
+ }
30
+
31
+ if (process.argv.includes("--help")) {
32
+ console.log(usage());
33
+ } else {
34
+ let secret;
35
+ try {
36
+ const options = parse(process.argv.slice(2));
37
+ secret = options.tokenEnv ? process.env[options.tokenEnv] : undefined;
38
+ if (options.tokenEnv && !secret) fail(`Environment variable ${options.tokenEnv} is empty or unset`);
39
+ const current = await currentContract(options, secret);
40
+ if (options.command === "capture") {
41
+ await writePublishedMcpContractBaseline(options.baseline, { version: options.version, contract: current });
42
+ console.log(`Captured published MCP contract ${options.version} at ${options.baseline}`);
43
+ } else {
44
+ const baselines = await loadBaselines(options.baselines);
45
+ try {
46
+ assertPublishedMcpContractCompatible(current, baselines);
47
+ } catch (error) {
48
+ throw new CommandError(1, message(error));
49
+ }
50
+ console.log(`Published MCP contract is compatible with ${baselines.length} baseline(s)`);
51
+ }
52
+ } catch (error) {
53
+ console.error(redact(message(error), secret));
54
+ process.exitCode = error instanceof CommandError ? error.exitCode : 2;
55
+ }
56
+ }
57
+
58
+ async function currentContract(options, token) {
59
+ if (options.factory) {
60
+ const imported = await import(pathToFileURL(resolve(options.factory)).href);
61
+ const factory = imported[options.factoryExport];
62
+ if (typeof factory !== "function") fail(`${options.factory} does not export ${options.factoryExport} as a function`);
63
+ const running = await startEmseepea(await factory(), {
64
+ clientName: "emseepea-contract",
65
+ protocolVersion: options.protocolVersion,
66
+ token,
67
+ });
68
+ try {
69
+ return await extractPublishedMcpContract(await running.connect());
70
+ } finally {
71
+ await running.close();
72
+ }
73
+ }
74
+
75
+ const client = new Client(
76
+ { name: "emseepea-contract", version: "0.0.0" },
77
+ options.protocolVersion === "2026-07-28"
78
+ ? { versionNegotiation: { mode: { pin: options.protocolVersion } } }
79
+ : { supportedProtocolVersions: [options.protocolVersion], versionNegotiation: { mode: "legacy" } },
80
+ );
81
+ try {
82
+ await client.connect(new StreamableHTTPClientTransport(
83
+ new URL(options.url),
84
+ token ? { authProvider: { token: async () => token } } : undefined,
85
+ ));
86
+ return await extractPublishedMcpContract(client);
87
+ } finally {
88
+ await client.close().catch(() => undefined);
89
+ }
90
+ }
91
+
92
+ async function loadBaselines(directory) {
93
+ const entries = (await readdir(directory, { withFileTypes: true }))
94
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
95
+ .map((entry) => entry.name)
96
+ .sort();
97
+ if (entries.length === 0) fail(`No JSON baselines found in ${directory}`);
98
+ const baselines = [];
99
+ for (const name of entries) {
100
+ const file = resolve(directory, name);
101
+ const baseline = JSON.parse(await readFile(file, "utf8"));
102
+ if (!isBaseline(baseline)) fail(`Invalid published MCP contract baseline: ${file}`);
103
+ baselines.push(baseline);
104
+ }
105
+ return baselines;
106
+ }
107
+
108
+ function parse(argv) {
109
+ const command = argv.shift();
110
+ if (command !== "capture" && command !== "check") fail(usage());
111
+ const values = new Map();
112
+ while (argv.length > 0) {
113
+ const option = argv.shift();
114
+ if (!option?.startsWith("--")) fail(`Unexpected argument: ${String(option)}`);
115
+ if (values.has(option)) fail(`Duplicate option: ${option}`);
116
+ const value = argv.shift();
117
+ if (!value || value.startsWith("--")) fail(`Missing value for ${option}`);
118
+ values.set(option, value);
119
+ }
120
+ const known = new Set([
121
+ "--factory", "--factory-export", "--url", "--protocol-version", "--token-env",
122
+ "--baseline", "--baselines", "--version",
123
+ ]);
124
+ for (const option of values.keys()) if (!known.has(option)) fail(`Unknown option: ${option}`);
125
+ const factory = values.get("--factory");
126
+ const url = values.get("--url");
127
+ if (Boolean(factory) === Boolean(url)) fail("Supply exactly one of --factory or --url");
128
+ if (!factory && values.has("--factory-export")) fail("--factory-export requires --factory");
129
+ if (url) {
130
+ try {
131
+ new URL(url);
132
+ } catch {
133
+ fail(`Invalid MCP URL: ${url}`);
134
+ }
135
+ }
136
+ const protocolVersion = values.get("--protocol-version") ?? "2026-07-28";
137
+ if (!protocolVersions.has(protocolVersion)) fail(`Unsupported protocol version: ${protocolVersion}`);
138
+ const tokenEnv = values.get("--token-env");
139
+ if (tokenEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokenEnv)) fail(`Invalid environment variable name: ${tokenEnv}`);
140
+
141
+ const common = {
142
+ command,
143
+ factory,
144
+ factoryExport: values.get("--factory-export") ?? "createApp",
145
+ url,
146
+ protocolVersion,
147
+ tokenEnv,
148
+ };
149
+ if (command === "capture") {
150
+ const baseline = values.get("--baseline");
151
+ const version = values.get("--version");
152
+ if (!baseline || !version) fail("capture requires --baseline and --version");
153
+ if (values.has("--baselines")) fail("capture does not accept --baselines");
154
+ return { ...common, baseline, version };
155
+ }
156
+ const baselines = values.get("--baselines");
157
+ if (!baselines) fail("check requires --baselines");
158
+ if (values.has("--baseline") || values.has("--version")) fail("check does not accept --baseline or --version");
159
+ return { ...common, baselines };
160
+ }
161
+
162
+ function isBaseline(value) {
163
+ return value !== null && typeof value === "object"
164
+ && typeof value.version === "string" && value.version.length > 0
165
+ && value.contract !== null && typeof value.contract === "object"
166
+ && Array.isArray(value.contract.tools) && Array.isArray(value.contract.resources);
167
+ }
168
+
169
+ function fail(text) {
170
+ throw new CommandError(2, text);
171
+ }
172
+
173
+ function message(error) {
174
+ return error instanceof Error ? error.message : String(error);
175
+ }
176
+
177
+ function redact(text, secret) {
178
+ return secret ? text.replaceAll(secret, "[redacted]") : text;
179
+ }
180
+
181
+ function usage() {
182
+ return [
183
+ "Usage:",
184
+ " emseepea-contract capture (--factory <module> | --url <mcp-url>) --version <label> --baseline <file> [options]",
185
+ " emseepea-contract check (--factory <module> | --url <mcp-url>) --baselines <directory> [options]",
186
+ "Options: --factory-export <name> --protocol-version <version> --token-env <name>",
187
+ ].join("\n");
188
+ }