@emseepea/testing 0.0.1 → 0.1.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
@@ -1,45 +1,90 @@
1
1
  # `@emseepea/testing`
2
2
 
3
- Test what a Model Context Protocol (MCP) server returns and whether a language
4
- model understands it.
3
+ Test whether an AI understands your MCP server's results, not just whether
4
+ the server returns valid data. Write the checks in JavaScript, with ordinary
5
+ imports, setup hooks, loops, and assertions.
5
6
 
6
- The package starts a Streamable HTTP server, connects through the official MCP
7
- client, and records the MCP operations used by each check. Semantic checks use
8
- Promptfoo and a model command supplied by the caller.
7
+ The code-first API below is being prepared for the next package release.
9
8
 
10
- The package is intended to publish under the `next` tag with the first
11
- pre-alpha release.
9
+ ## Keep the Two Kinds of Test Separate
12
10
 
13
- ## Add Semantic Checks to an Example
11
+ Each example has two directories:
14
12
 
15
- Install the package as a development dependency:
13
+ - `test/` holds ordinary tests. Run them with `npm test`.
14
+ - `eval/` holds tests that ask a language model to interpret MCP results.
15
+ Run them with `npm run test:llm`.
16
16
 
17
- ```sh
18
- npm install --save-dev @emseepea/testing@next
19
- ```
17
+ The commands do not run each other's tests. Both directories are linted.
18
+ Organize larger suites into subdirectories, such as `eval/inventory/`.
19
+ The LLM runner finds every `*.test.mjs` file inside `eval/`.
20
20
 
21
- Create `eval.yaml` beside the example. Say which server to start, which MCP
22
- operation to run, and what a correct answer must mean:
23
-
24
- ```yaml
25
- description: Coffee details keep each concept distinct
26
- server: ./dist/server.js
27
- operations:
28
- - method: tools/call
29
- name: get-bean-details
30
- arguments:
31
- name: Highland Bloom
32
- question: What are this coffee's origin, variety, process, roast, and tasting notes?
33
- criticalFacts: [Sample Highlands, Bourbon, natural, medium, berry, cocoa]
34
- criteria: Keep origin, variety, process, roast, and tasting notes distinct.
35
- ```
21
+ ## Write an LLM Test
22
+
23
+ Import `semanticTest` from `@emseepea/testing/semantic` in a file such as
24
+ `eval/coffee.test.mjs`. Start with the
25
+ [basic coffee test](https://github.com/windyroad/emseepea/blob/main/examples/basic-no-ui/eval/meaning.test.mjs)
26
+ or the
27
+ [report test with repeated calls](https://github.com/windyroad/emseepea/blob/main/examples/multi-instance/eval/meaning.test.mjs).
28
+
29
+ Each test describes:
30
+
31
+ - `server`: the file URL of your built server entry point. It must print its
32
+ local `http://127.0.0.1:PORT/mcp` address when ready.
33
+ - `exercise`: code that calls tools, reads resources, or requests prompts
34
+ through the supplied MCP client. Use assertions to check the returned data.
35
+ - `question`: what to ask the model about those results.
36
+ - `criticalFacts`: text or regular expressions that every answer must match.
37
+ Text checks ignore letter case; patterns use their own flags.
38
+ - `criteria`: what a correct answer must mean, including mistakes to avoid.
39
+ - `requiredPaths`: which MCP operations must supply the evidence, written as
40
+ `method:target`, such as `tools/call:get-bean-details`.
41
+
42
+ Use the optional `assertAnswer(answer)` callback for your own exact checks on
43
+ the model's answer. Each test needs a unique name within its file.
44
+
45
+ The repeated-report example asks for JSON and checks every field; it does not
46
+ test free-form prose.
47
+
48
+ The callback's client supports `callTool`, `readResource`, and `getPrompt`.
49
+ Pass request parameters as the single argument to each method. The helper
50
+ records every call and includes tool progress in the model's input.
36
51
 
37
- Build the example, then run:
52
+ ## Run the Checks
53
+
54
+ In a copied example, install its dependencies and run:
38
55
 
39
56
  ```sh
40
- npx emseepea-test eval.yaml
57
+ npm test
58
+ npm run lint
59
+ npm run test:llm
41
60
  ```
42
61
 
43
- The check calls the server through the official MCP client. It runs three
44
- answers and three independent judgments per answer, and saves the evidence
45
- under `artifacts/llm-eval/`.
62
+ The last command requires the Claude CLI on your command path and a signed-in
63
+ Claude account. If needed, run `claude auth login` first. This package does not
64
+ bundle the CLI or copy your login credentials.
65
+
66
+ For your own project, add `@emseepea/testing` as a development dependency and
67
+ set `test:llm` to build your server and run `emseepea-test eval`.
68
+
69
+ For a server that requires sign-in, set `authTokenEnvironment` to the name of
70
+ an environment variable containing its test access token. Do not put real
71
+ tokens in test files. The optional `environment` object supplies ordinary test
72
+ settings to the server; model-provider credentials are not passed through.
73
+
74
+ ## What a Passing Check Means
75
+
76
+ For each test, the runner asks the same question three times. Each answer gets
77
+ three independent model judgments, for nine judgments in total. A missing
78
+ fact, failed assertion, rejected answer, or missing MCP call fails the test.
79
+ Failed answers are not retried or taken from a cache.
80
+
81
+ Your test code makes the MCP calls. The model interprets the collected results;
82
+ it cannot call tools itself. These checks prove interpretation of that material,
83
+ not that the model can independently choose the right tool.
84
+
85
+ Results are saved to `artifacts/llm-eval/evidence.json`. The report contains
86
+ outcomes and operation hashes, not raw MCP results or model answers.
87
+
88
+ Repository tests also use a simulated model to check the runner without spending
89
+ model credits. Those `--smoke` checks test the wiring only. They do not prove
90
+ that a real model understands the result and cannot approve a release.
@@ -0,0 +1,16 @@
1
+ import { Client } from "@modelcontextprotocol/client";
2
+ export interface TestCleanup {
3
+ after(cleanup: () => Promise<void>): void;
4
+ }
5
+ export interface StartMcpServerOptions {
6
+ clientName?: string;
7
+ environment?: NodeJS.ProcessEnv;
8
+ startupTimeoutMs?: number;
9
+ token?: string;
10
+ }
11
+ export interface RunningMcpServer {
12
+ connect(token?: string): Promise<Client>;
13
+ url: URL;
14
+ }
15
+ export declare function startMcpServer(test: TestCleanup, serverUrl: URL, options?: StartMcpServerOptions): Promise<RunningMcpServer>;
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAiC,MAAM,8BAA8B,CAAC;AAErF,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzC,GAAG,EAAE,GAAG,CAAC;CACV;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,WAAW,EACjB,SAAS,EAAE,GAAG,EACd,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,gBAAgB,CAAC,CAgE3B"}
package/dist/index.js ADDED
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
4
+ export async function startMcpServer(test, serverUrl, options = {}) {
5
+ const child = spawn(process.execPath, [fileURLToPath(serverUrl)], {
6
+ env: {
7
+ ...process.env,
8
+ NODE_ENV: "test",
9
+ PORT: "0",
10
+ ...options.environment,
11
+ },
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ const clients = [];
15
+ let output = "";
16
+ let errors = "";
17
+ child.stdout.on("data", (chunk) => { output = `${output}${chunk}`.slice(-16_384); });
18
+ child.stderr.on("data", (chunk) => { errors = `${errors}${chunk}`.slice(-16_384); });
19
+ const url = await new Promise((resolve, reject) => {
20
+ const finish = (error, value) => {
21
+ clearTimeout(timer);
22
+ child.stdout.off("data", inspect);
23
+ child.off("error", failed);
24
+ child.off("exit", exited);
25
+ if (error)
26
+ reject(error);
27
+ else if (value)
28
+ resolve(value);
29
+ };
30
+ const inspect = () => {
31
+ const match = output.match(/http:\/\/127\.0\.0\.1:\d+\/mcp/);
32
+ if (match?.[0])
33
+ finish(undefined, new URL(match[0]));
34
+ };
35
+ const failed = (error) => finish(error);
36
+ const exited = (code) => finish(new Error(`MCP server exited ${String(code)}: ${errors}`));
37
+ const timer = setTimeout(() => finish(new Error(`MCP server startup timed out: ${errors}`)), options.startupTimeoutMs ?? 15_000);
38
+ child.stdout.on("data", inspect);
39
+ child.once("error", failed);
40
+ child.once("exit", exited);
41
+ inspect();
42
+ }).catch(async (error) => {
43
+ await stopProcess(child);
44
+ throw error;
45
+ });
46
+ test.after(async () => {
47
+ await Promise.allSettled(clients.map((client) => client.close()));
48
+ await stopProcess(child);
49
+ });
50
+ return {
51
+ url,
52
+ async connect(token = options.token) {
53
+ const client = new Client({ name: options.clientName ?? "emseepea-test", version: "0.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
54
+ await client.connect(new StreamableHTTPClientTransport(url, token ? { authProvider: { token: async () => token } } : undefined));
55
+ clients.push(client);
56
+ return client;
57
+ },
58
+ };
59
+ }
60
+ async function stopProcess(child) {
61
+ if (child.exitCode !== null)
62
+ return;
63
+ child.kill("SIGTERM");
64
+ await Promise.race([
65
+ new Promise((resolve) => child.once("close", () => resolve())),
66
+ new Promise((resolve) => setTimeout(resolve, 3_000)),
67
+ ]);
68
+ if (child.exitCode === null)
69
+ child.kill("SIGKILL");
70
+ }
71
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,10 @@
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
22
  "import": "./dist/index.js"
23
+ },
24
+ "./semantic": {
25
+ "types": "./semantic/test.d.mts",
26
+ "import": "./semantic/test.mjs"
23
27
  }
24
28
  },
25
29
  "bin": {
@@ -33,12 +37,12 @@
33
37
  ],
34
38
  "scripts": {
35
39
  "build": "tsc -p tsconfig.json",
40
+ "prepack": "npm run build",
36
41
  "test": "npm run build && npm run test:built",
37
42
  "test:built": "node --test test/*.test.mjs"
38
43
  },
39
44
  "dependencies": {
40
- "@modelcontextprotocol/client": "2.0.0",
41
- "promptfoo": "0.122.1"
45
+ "@modelcontextprotocol/client": "2.0.0"
42
46
  },
43
47
  "engines": {
44
48
  "node": ">=22"
package/semantic/case.mjs CHANGED
@@ -1,189 +1,44 @@
1
- import { readFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
1
  import { fileURLToPath } from "node:url";
4
-
5
- const methods = new Set(["tools/call", "resources/read", "prompts/get"]);
6
-
7
- export async function loadSemanticCase(path) {
8
- const absolutePath = path instanceof URL ? fileURLToPath(path) : resolve(path);
9
- const value = parseCase(await readFile(absolutePath, "utf8"));
10
- if (!value || typeof value !== "object" || Array.isArray(value)) fail("must be an object");
11
- for (const key of ["description", "question", "criteria", "server"]) {
12
- if (typeof value[key] !== "string" || value[key].trim() === "") fail(`${key} must be text`);
13
- }
14
- if (!Array.isArray(value.criticalFacts) || value.criticalFacts.some((fact) => typeof fact !== "string" || !fact)) {
15
- fail("criticalFacts must be a non-empty text list");
16
- }
17
- if (!Array.isArray(value.operations) || value.operations.length === 0) fail("operations must not be empty");
18
- for (const operation of value.operations) {
19
- if (!operation || typeof operation !== "object" || !methods.has(operation.method)) {
20
- fail("each operation needs a supported method");
21
- }
22
- const target = operation.name ?? operation.uri;
23
- if (typeof target !== "string" || target === "") fail("each operation needs a name or uri");
24
- }
25
- if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" || Array.isArray(value.environment))) {
26
- fail("environment must be an object");
27
- }
28
- if (value.authTokenEnvironment !== undefined && typeof value.authTokenEnvironment !== "string") {
29
- fail("authTokenEnvironment must be text");
30
- }
31
- if (value.authToken !== undefined && typeof value.authToken !== "string") fail("authToken must be text");
32
- return {
33
- ...value,
34
- path: absolutePath,
35
- directory: dirname(absolutePath),
36
- server: resolve(dirname(absolutePath), value.server),
37
- };
38
- }
39
-
40
- export function requiredPaths(testCase) {
41
- return testCase.operations.map((operation) => (
42
- `${operation.method}:${operation.name ?? operation.uri}`
43
- ));
44
- }
45
-
46
- function fail(message) {
47
- throw new Error(`Invalid semantic case: ${message}`);
48
- }
49
-
50
- function parseCase(source) {
51
- const lines = source.split(/\r?\n/);
52
- const value = {};
53
- for (let index = 0; index < lines.length;) {
54
- const line = lines[index];
55
- if (line.trim() === "" || line.trimStart().startsWith("#")) {
56
- index += 1;
57
- continue;
58
- }
59
- const top = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(line);
60
- if (!top) fail(`unsupported line: ${line}`);
61
- const [, key, raw = ""] = top;
62
- const rest = raw.trimEnd();
63
- if (key === "operations") {
64
- const parsed = parseOperations(lines, index + 1);
65
- value.operations = parsed.value;
66
- index = parsed.next;
67
- } else if (rest === ">-") {
68
- const parsed = parseFoldedText(lines, index + 1);
69
- value[key] = parsed.value;
70
- index = parsed.next;
71
- } else if (key === "environment" && rest === "") {
72
- const parsed = parseObject(lines, index + 1, " ");
73
- value.environment = parsed.value;
74
- index = parsed.next;
75
- } else if (rest === "") {
76
- const parsed = parseList(lines, index + 1);
77
- value[key] = parsed.value;
78
- index = parsed.next;
79
- } else if (rest.startsWith("[") && rest.endsWith("]")) {
80
- value[key] = rest.slice(1, -1).split(",").map((item) => unquote(item.trim())).filter(Boolean);
81
- index += 1;
82
- } else {
83
- value[key] = unquote(rest);
84
- index += 1;
85
- }
86
- }
87
- return value;
88
- }
89
-
90
- function parseOperations(lines, start) {
91
- const operations = [];
92
- let index = start;
93
- while (index < lines.length) {
94
- const line = lines[index];
95
- if (line.trim() === "") {
96
- index += 1;
97
- continue;
98
- }
99
- if (!line.startsWith(" - ")) break;
100
- const operation = {};
101
- const first = line.slice(4);
102
- applyPair(operation, first);
103
- index += 1;
104
- while (index < lines.length) {
105
- const current = lines[index];
106
- if (current.trim() === "") {
107
- index += 1;
108
- continue;
109
- }
110
- if (current.startsWith(" - ") || !current.startsWith(" ")) break;
111
- const pair = /^ ([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/.exec(current);
112
- if (!pair) fail(`unsupported operation line: ${current}`);
113
- const [, key, raw = ""] = pair;
114
- if (key === "arguments" && raw.trimEnd() === "") {
115
- const parsed = parseObject(lines, index + 1, " ", true);
116
- operation.arguments = parsed.value;
117
- index = parsed.next;
118
- } else {
119
- operation[key] = unquote(raw.trimEnd());
120
- index += 1;
121
- }
2
+ import { dirname } from "node:path";
3
+
4
+ export function validateSemanticCase(value) {
5
+ if (!value || typeof value !== "object") throw new Error("Semantic test needs options");
6
+ for (const key of ["question", "criteria"]) {
7
+ if (typeof value[key] !== "string" || !value[key].trim()) throw new Error(`${key} must be text`);
8
+ }
9
+ if (!(value.server instanceof URL) || value.server.protocol !== "file:") throw new Error("server must be a file URL");
10
+ for (const key of ["criticalFacts", "requiredPaths"]) {
11
+ if (!Array.isArray(value[key]) || !value[key].length || value[key].some((item) =>
12
+ !(key === "criticalFacts" && item instanceof RegExp) && (typeof item !== "string" || !item.trim()))) {
13
+ throw new Error(`${key} must contain text${key === "criticalFacts" ? " or regular expressions" : ""}`);
122
14
  }
123
- operations.push(operation);
124
- }
125
- return { value: operations, next: index };
126
- }
127
-
128
- function parseList(lines, start) {
129
- const items = [];
130
- let index = start;
131
- while (index < lines.length) {
132
- const item = /^ -\s+(.+)$/.exec(lines[index]);
133
- if (!item) break;
134
- items.push(unquote(item[1].trimEnd()));
135
- index += 1;
136
- }
137
- if (items.length === 0) fail("expected a list");
138
- return { value: items, next: index };
139
- }
140
-
141
- function parseObject(lines, start, indent, parseScalars = false) {
142
- const object = {};
143
- let index = start;
144
- while (index < lines.length) {
145
- if (!lines[index].startsWith(indent)) break;
146
- applyPair(object, lines[index].slice(indent.length), parseScalars);
147
- index += 1;
148
15
  }
149
- return { value: object, next: index };
150
- }
151
-
152
- function parseFoldedText(lines, start) {
153
- const parts = [];
154
- let index = start;
155
- while (index < lines.length) {
156
- if (!lines[index].startsWith(" ")) break;
157
- parts.push(lines[index].trim());
158
- index += 1;
16
+ if (typeof value.exercise !== "function") throw new Error("exercise must be a function");
17
+ if (value.assertAnswer !== undefined && typeof value.assertAnswer !== "function") throw new Error("assertAnswer must be a function");
18
+ if (value.requiredPaths.some((path) => !/^(tools\/call|resources\/read|prompts\/get):.+$/.test(path))) {
19
+ throw new Error("requiredPaths must name a supported MCP method and target");
159
20
  }
160
- return { value: parts.join(" "), next: index };
161
- }
162
-
163
- function applyPair(target, source, parseScalars = false) {
164
- const pair = /^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/.exec(source);
165
- if (!pair) fail(`expected key-value pair: ${source}`);
166
- target[pair[1]] = parseScalars ? parseArgumentScalar(pair[2]) : unquote(pair[2].trimEnd());
167
- }
168
-
169
- function parseArgumentScalar(value) {
170
- const trimmed = value.trim();
171
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
172
- return unquote(trimmed);
21
+ for (const key of ["authToken", "authTokenEnvironment"]) {
22
+ if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key].trim())) throw new Error(`${key} must be non-empty text`);
173
23
  }
174
- if (trimmed === "true") return true;
175
- if (trimmed === "false") return false;
176
- if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed)) {
177
- const number = Number(trimmed);
178
- if (Number.isFinite(number)) return number;
24
+ if (value.authToken !== undefined && value.authTokenEnvironment !== undefined) throw new Error("Choose one authentication source");
25
+ if (value.environment !== undefined && (!value.environment || typeof value.environment !== "object" ||
26
+ Array.isArray(value.environment) || Object.values(value.environment).some((item) => typeof item !== "string"))) {
27
+ throw new Error("environment must contain string values");
179
28
  }
180
- return trimmed;
29
+ const server = fileURLToPath(value.server);
30
+ return { ...value, server, directory: dirname(server) };
181
31
  }
182
32
 
183
- function unquote(value) {
184
- const trimmed = value.trim();
185
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
186
- return trimmed.slice(1, -1);
33
+ export function checkMeaningEvidence(options, answer, paths) {
34
+ const seen = new Set(paths.map(({ method, target }) => `${method}:${target}`));
35
+ if (options.requiredPaths.some((path) => !seen.has(path))) throw new Error("Required MCP path evidence is missing");
36
+ const missingFactIndices = options.criticalFacts.flatMap((fact, index) =>
37
+ (fact instanceof RegExp ? new RegExp(fact.source, fact.flags).test(answer)
38
+ : answer.toLowerCase().includes(fact.toLowerCase())) ? [] : [index]);
39
+ if (missingFactIndices.length) {
40
+ throw Object.assign(new Error("Answer is missing a required fact"), {
41
+ code: "missing-critical-facts", missingFactIndices,
42
+ });
187
43
  }
188
- return trimmed;
189
44
  }