benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files 27603c12d06cd7899d93c0fbb8ce9331040b8a8c
| 1 | // Contract and regression tests for the tool surface. |
| 2 | // |
| 3 | // These run the real built server over stdio against a local stand-in for the |
| 4 | // TypeSafe API, so each assertion is about what the server actually sends and |
| 5 | // returns. Every test named "regression" pins a defect that was confirmed on |
| 6 | // the wire before it was fixed. |
| 7 | |
| 8 | import assert from "node:assert/strict"; |
| 9 | import test from "node:test"; |
| 10 | import { payload, startMock, withClient } from "./helpers.mjs"; |
| 11 | |
| 12 | const TOOLS = ["jev_ask", "jev_check", "jev_classify", "jev_models", "jev_score"]; |
| 13 | |
| 14 | // ── Contract ──────────────────────────────────────────────────────────────── |
| 15 | |
| 16 | test("advertises exactly the five judgment tools", async () => { |
| 17 | await withClient({ withKey: false }, async (client) => { |
| 18 | const { tools } = await client.listTools(); |
| 19 | assert.deepEqual(tools.map((t) => t.name).sort(), TOOLS); |
| 20 | }); |
| 21 | }); |
| 22 | |
| 23 | test("every tool documents itself and declares both input and output schemas", async () => { |
| 24 | await withClient({ withKey: false }, async (client) => { |
| 25 | const { tools } = await client.listTools(); |
| 26 | for (const tool of tools) { |
| 27 | assert.ok(tool.description?.length > 40, `${tool.name} needs a real description`); |
| 28 | assert.ok(tool.inputSchema, `${tool.name} has no inputSchema`); |
| 29 | assert.equal(tool.inputSchema.type, "object"); |
| 30 | assert.ok(tool.outputSchema, `${tool.name} has no outputSchema, so callers get an opaque string`); |
| 31 | } |
| 32 | }); |
| 33 | }); |
| 34 | |
| 35 | test("classify requires caller-supplied options, so the model cannot invent one", async () => { |
| 36 | await withClient({ withKey: false }, async (client) => { |
| 37 | const { tools } = await client.listTools(); |
| 38 | const classify = tools.find((t) => t.name === "jev_classify"); |
| 39 | for (const field of ["state", "question", "options"]) { |
| 40 | assert.ok(classify.inputSchema.required.includes(field), `${field} must be required`); |
| 41 | } |
| 42 | const scoreTool = tools.find((t) => t.name === "jev_score"); |
| 43 | assert.ok(scoreTool.inputSchema.required.includes("levels")); |
| 44 | assert.equal(scoreTool.inputSchema.properties.levels.minItems, 2); |
| 45 | }); |
| 46 | }); |
| 47 | |
| 48 | test("a missing API key fails loudly and says what to do about it", async () => { |
| 49 | await withClient({ withKey: false }, async (client) => { |
| 50 | const result = await client.callTool({ |
| 51 | name: "jev_check", |
| 52 | arguments: { state: "anything", question: "Is this a greeting?" }, |
| 53 | }); |
| 54 | assert.equal(result.isError, true, "must report an error, not a fabricated answer"); |
| 55 | const { error } = payload(result); |
| 56 | assert.equal(error.kind, "no_api_key"); |
| 57 | assert.equal(error.retryable, false); |
| 58 | assert.match(error.hint, /environment/i); |
| 59 | }); |
| 60 | }); |
| 61 | |
| 62 | test("falls back to a key file when the environment carries no key", async () => { |
| 63 | // An MCP server inherits the client's environment, not your shell's, so a key |
| 64 | // exported from a shell profile never arrives. The file fallback is the only |
| 65 | // route that reaches every spawn path, which makes it worth pinning here. |
| 66 | const { mkdtempSync, writeFileSync } = await import("node:fs"); |
| 67 | const { tmpdir } = await import("node:os"); |
| 68 | const { join } = await import("node:path"); |
| 69 | |
| 70 | const keyFile = join(mkdtempSync(join(tmpdir(), "jev-key-")), "key"); |
| 71 | writeFileSync(keyFile, " value-for-the-local-stand-in\n", { mode: 0o600 }); |
| 72 | |
| 73 | const mock = await startMock(); |
| 74 | try { |
| 75 | await withClient({ baseUrl: mock.url, withKey: false, env: { JEV_KEY_FILE: keyFile } }, async (client) => { |
| 76 | const result = await client.callTool({ |
| 77 | name: "jev_check", |
| 78 | arguments: { state: "s", question: "Is this fine?" }, |
| 79 | }); |
| 80 | assert.notEqual(result.isError, true, "the key file should satisfy the credential check"); |
| 81 | assert.equal(typeof payload(result).probability_yes, "number"); |
| 82 | assert.equal(mock.requests.length, 1, "the request must actually reach the API"); |
| 83 | }); |
| 84 | } finally { |
| 85 | await mock.close(); |
| 86 | } |
| 87 | }); |
| 88 | |
| 89 | // ── Regressions confirmed on the wire ─────────────────────────────────────── |
| 90 | |
| 91 | test("regression: an option the caller names 'none' is never overwritten", async () => { |
| 92 | const mock = await startMock(); |
| 93 | const callerMeaning = "Already resolved; no action needed."; |
| 94 | try { |
| 95 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 96 | const result = await client.callTool({ |
| 97 | name: "jev_classify", |
| 98 | arguments: { |
| 99 | state: "s", |
| 100 | question: "What should happen next?", |
| 101 | options: { none: callerMeaning, escalate: "Send to a human" }, |
| 102 | }, |
| 103 | }); |
| 104 | |
| 105 | const sent = mock.only().questions.classify.criteria; |
| 106 | assert.equal(sent.none, callerMeaning, "the caller's meaning must reach the model intact"); |
| 107 | assert.equal(sent.none_of_these, "None of the other options fits."); |
| 108 | |
| 109 | const body = payload(result); |
| 110 | assert.equal(body.none_option, "none_of_these", "the caller must be told which key means no-match"); |
| 111 | }); |
| 112 | } finally { |
| 113 | await mock.close(); |
| 114 | } |
| 115 | }); |
| 116 | |
| 117 | test("regression: add_none false sends exactly the caller's options", async () => { |
| 118 | const mock = await startMock(); |
| 119 | try { |
| 120 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 121 | const result = await client.callTool({ |
| 122 | name: "jev_classify", |
| 123 | arguments: { state: "s", question: "q", options: { a: "first", b: "second" }, add_none: false }, |
| 124 | }); |
| 125 | assert.deepEqual(Object.keys(mock.only().questions.classify.criteria).sort(), ["a", "b"]); |
| 126 | assert.equal(payload(result).none_option, null); |
| 127 | }); |
| 128 | } finally { |
| 129 | await mock.close(); |
| 130 | } |
| 131 | }); |
| 132 | |
| 133 | test("regression: an empty or oversized option set fails before any request is sent", async () => { |
| 134 | const mock = await startMock(); |
| 135 | try { |
| 136 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 137 | const empty = await client.callTool({ |
| 138 | name: "jev_classify", |
| 139 | arguments: { state: "s", question: "q", options: {}, add_none: false }, |
| 140 | }); |
| 141 | assert.equal(empty.isError, true); |
| 142 | assert.match(payload(empty).error.message, /at least one option/); |
| 143 | |
| 144 | const tooMany = Object.fromEntries(Array.from({ length: 256 }, (_, i) => [`o${i}`, null])); |
| 145 | const oversized = await client.callTool({ |
| 146 | name: "jev_classify", |
| 147 | arguments: { state: "s", question: "q", options: tooMany, add_none: false }, |
| 148 | }); |
| 149 | assert.equal(oversized.isError, true); |
| 150 | assert.match(payload(oversized).error.message, /at most 255 options/); |
| 151 | |
| 152 | assert.equal(mock.requests.length, 0, "neither request should reach the API"); |
| 153 | }); |
| 154 | } finally { |
| 155 | await mock.close(); |
| 156 | } |
| 157 | }); |
| 158 | |
| 159 | test("regression: duplicate question ids fail instead of silently dropping a question", async () => { |
| 160 | const mock = await startMock(); |
| 161 | try { |
| 162 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 163 | const result = await client.callTool({ |
| 164 | name: "jev_ask", |
| 165 | arguments: { |
| 166 | state: "s", |
| 167 | questions: [ |
| 168 | { id: "dup", type: "check", question: "first" }, |
| 169 | { id: "dup", type: "check", question: "second" }, |
| 170 | ], |
| 171 | }, |
| 172 | }); |
| 173 | assert.equal(result.isError, true); |
| 174 | assert.match(payload(result).error.message, /Duplicate question ids: "dup"/); |
| 175 | assert.equal(mock.requests.length, 0, "no partial request should be sent"); |
| 176 | }); |
| 177 | } finally { |
| 178 | await mock.close(); |
| 179 | } |
| 180 | }); |
| 181 | |
| 182 | test("regression: jev_ask honours add_none and passes yes/no meanings", async () => { |
| 183 | const mock = await startMock(); |
| 184 | try { |
| 185 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 186 | await client.callTool({ |
| 187 | name: "jev_ask", |
| 188 | arguments: { |
| 189 | state: "s", |
| 190 | questions: [ |
| 191 | { id: "pick", type: "classify", question: "q", options: { a: "d", b: "d" }, add_none: false }, |
| 192 | { id: "flag", type: "check", question: "q", yes_means: "It is urgent", no_means: "It can wait" }, |
| 193 | ], |
| 194 | }, |
| 195 | }); |
| 196 | |
| 197 | const sent = mock.only().questions; |
| 198 | assert.deepEqual(Object.keys(sent.pick.criteria).sort(), ["a", "b"], "add_none:false must be honoured"); |
| 199 | assert.deepEqual(sent.flag.criteria, { true: "It is urgent", false: "It can wait" }); |
| 200 | }); |
| 201 | } finally { |
| 202 | await mock.close(); |
| 203 | } |
| 204 | }); |
| 205 | |
| 206 | test("regression: a verbose SDK log level does not corrupt the JSON-RPC stream", async () => { |
| 207 | const mock = await startMock(); |
| 208 | try { |
| 209 | await withClient({ baseUrl: mock.url, env: { TYPESAFE_LOG_LEVEL: "debug" } }, async (client) => { |
| 210 | const result = await client.callTool({ |
| 211 | name: "jev_check", |
| 212 | arguments: { state: "s", question: "Is this fine?" }, |
| 213 | }); |
| 214 | assert.notEqual(result.isError, true, "debug logging must not break the connection"); |
| 215 | assert.equal(typeof payload(result).probability_yes, "number"); |
| 216 | }); |
| 217 | } finally { |
| 218 | await mock.close(); |
| 219 | } |
| 220 | }); |
| 221 | |
| 222 | // ── Behaviour ─────────────────────────────────────────────────────────────── |
| 223 | |
| 224 | test("results carry structured content matching the text block", async () => { |
| 225 | const mock = await startMock(); |
| 226 | try { |
| 227 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 228 | const result = await client.callTool({ |
| 229 | name: "jev_classify", |
| 230 | arguments: { state: "s", question: "q", options: { a: "d", b: "d" } }, |
| 231 | }); |
| 232 | assert.ok(result.structuredContent, "clients should get typed data, not a JSON string"); |
| 233 | assert.deepEqual(result.structuredContent, payload(result)); |
| 234 | assert.equal(typeof result.structuredContent.probabilities.a, "number"); |
| 235 | }); |
| 236 | } finally { |
| 237 | await mock.close(); |
| 238 | } |
| 239 | }); |
| 240 | |
| 241 | test("confidence drives the recommended action", async () => { |
| 242 | const mock = await startMock(); |
| 243 | try { |
| 244 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 245 | const args = { state: "s", question: "q", options: { a: "d", b: "d" } }; |
| 246 | |
| 247 | mock.state.confidence = 0.95; |
| 248 | assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "act"); |
| 249 | |
| 250 | mock.state.confidence = 0.6; |
| 251 | assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "review"); |
| 252 | |
| 253 | mock.state.confidence = 0.2; |
| 254 | assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "abstain"); |
| 255 | |
| 256 | mock.state.confidence = 0.6; |
| 257 | const strict = await client.callTool({ name: "jev_classify", arguments: { ...args, act_above: 0.5 } }); |
| 258 | assert.equal(payload(strict).action, "act", "caller thresholds must override the defaults"); |
| 259 | }); |
| 260 | } finally { |
| 261 | await mock.close(); |
| 262 | } |
| 263 | }); |
| 264 | |
| 265 | test("a near-even probability is reported as uncertain, not rounded to a yes", async () => { |
| 266 | const mock = await startMock(); |
| 267 | try { |
| 268 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 269 | mock.state.noul = 0.52; |
| 270 | const result = await client.callTool({ |
| 271 | name: "jev_check", |
| 272 | arguments: { state: "s", question: "Is this urgent?" }, |
| 273 | }); |
| 274 | const body = payload(result); |
| 275 | assert.equal(body.probability_yes, 0.52); |
| 276 | assert.equal(body.verdict, "uncertain"); |
| 277 | }); |
| 278 | } finally { |
| 279 | await mock.close(); |
| 280 | } |
| 281 | }); |
| 282 | |
| 283 | test("oversized state is rejected before it costs anything", async () => { |
| 284 | const mock = await startMock(); |
| 285 | try { |
| 286 | await withClient({ baseUrl: mock.url, env: { JEV_MAX_STATE_CHARS: "50" } }, async (client) => { |
| 287 | const result = await client.callTool({ |
| 288 | name: "jev_check", |
| 289 | arguments: { state: "x".repeat(200), question: "q" }, |
| 290 | }); |
| 291 | assert.equal(result.isError, true); |
| 292 | assert.match(payload(result).error.message, /above the 50 limit/); |
| 293 | assert.equal(mock.requests.length, 0); |
| 294 | }); |
| 295 | } finally { |
| 296 | await mock.close(); |
| 297 | } |
| 298 | }); |
| 299 | |
| 300 | test("an unusable timeout falls back instead of disabling the timeout", async () => { |
| 301 | const mock = await startMock(); |
| 302 | try { |
| 303 | await withClient({ baseUrl: mock.url, env: { JEV_TIMEOUT_MS: "not-a-number" } }, async (client) => { |
| 304 | const result = await client.callTool({ |
| 305 | name: "jev_check", |
| 306 | arguments: { state: "s", question: "q" }, |
| 307 | }); |
| 308 | assert.notEqual(result.isError, true, "a malformed timeout must not break the server"); |
| 309 | }); |
| 310 | } finally { |
| 311 | await mock.close(); |
| 312 | } |
| 313 | }); |
| 314 | |
| 315 | test("API failures are classified so a caller can tell retryable from terminal", async () => { |
| 316 | const mock = await startMock(); |
| 317 | try { |
| 318 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 319 | mock.state.status = 401; |
| 320 | const unauthorized = await client.callTool({ name: "jev_check", arguments: { state: "s", question: "q" } }); |
| 321 | assert.equal(unauthorized.isError, true); |
| 322 | const { error } = payload(unauthorized); |
| 323 | assert.equal(error.kind, "authentication"); |
| 324 | assert.equal(error.retryable, false); |
| 325 | assert.equal(error.requestId, "req_test_123", "the request id support needs must survive"); |
| 326 | }); |
| 327 | } finally { |
| 328 | await mock.close(); |
| 329 | } |
| 330 | }); |
| 331 | |
| 332 | test("jev_models reports the active model and what the key can use", async () => { |
| 333 | const mock = await startMock(); |
| 334 | try { |
| 335 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 336 | const body = payload(await client.callTool({ name: "jev_models", arguments: {} })); |
| 337 | assert.equal(body.active_model, "jev-latest"); |
| 338 | assert.equal(body.models[0].name, "jev-latest"); |
| 339 | }); |
| 340 | } finally { |
| 341 | await mock.close(); |
| 342 | } |
| 343 | }); |
| 344 | |
| 345 | test("one jev_ask call batches every question into a single request", async () => { |
| 346 | const mock = await startMock(); |
| 347 | try { |
| 348 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 349 | const result = await client.callTool({ |
| 350 | name: "jev_ask", |
| 351 | arguments: { |
| 352 | state: { ticket: "My card was charged twice." }, |
| 353 | questions: [ |
| 354 | { id: "billing", type: "check", question: "Is this about billing?" }, |
| 355 | { id: "team", type: "classify", question: "Which team?", options: { billing: "d", tech: "d" } }, |
| 356 | { id: "anger", type: "score", question: "How angry?", levels: ["calm", "annoyed", "furious"] }, |
| 357 | ], |
| 358 | }, |
| 359 | }); |
| 360 | |
| 361 | assert.equal(mock.requests.length, 1, "three questions must cost one round trip"); |
| 362 | assert.deepEqual(Object.keys(mock.only().questions).sort(), ["anger", "billing", "team"]); |
| 363 | |
| 364 | const body = payload(result); |
| 365 | assert.deepEqual(Object.keys(body.answers).sort(), ["anger", "billing", "team"]); |
| 366 | assert.equal(body.answers.team.action, "act", "choice answers carry a gate"); |
| 367 | assert.equal(body.answers.billing.action, undefined, "a noul has no confidence to gate on"); |
| 368 | }); |
| 369 | } finally { |
| 370 | await mock.close(); |
| 371 | } |
| 372 | }); |
| 373 | |
| 374 | // ── Answer validation on the wire ─────────────────────────────────────────── |
| 375 | |
| 376 | test("regression: a choice the model was never offered is an error, not a result", async () => { |
| 377 | const mock = await startMock(); |
| 378 | try { |
| 379 | mock.state.answers = { |
| 380 | classify: { type: "choice", choice: "delete_everything", confidence: 0.99, probabilities: { delete_everything: 0.99, safe: 0.01 } }, |
| 381 | }; |
| 382 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 383 | const result = await client.callTool({ |
| 384 | name: "jev_classify", |
| 385 | arguments: { state: "rm -rf build/", question: "Which operation?", options: { safe: "Reads only", risky: "Destroys work" } }, |
| 386 | }); |
| 387 | assert.equal(result.isError, true, "an unoffered choice must not come back as a result"); |
| 388 | const { error } = payload(result); |
| 389 | assert.equal(error.kind, "malformed_response"); |
| 390 | assert.match(error.message, /delete_everything/); |
| 391 | }); |
| 392 | } finally { |
| 393 | await mock.close(); |
| 394 | } |
| 395 | }); |
| 396 | |
| 397 | test("regression: jev_ask fails when any answer is missing or malformed instead of returning a partial set", async () => { |
| 398 | const mock = await startMock(); |
| 399 | try { |
| 400 | mock.state.answers = { first: { type: "noul", noul: 0.2 } }; |
| 401 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 402 | const result = await client.callTool({ |
| 403 | name: "jev_ask", |
| 404 | arguments: { |
| 405 | state: "x", |
| 406 | questions: [ |
| 407 | { id: "first", type: "check", question: "A?" }, |
| 408 | { id: "second", type: "check", question: "B?" }, |
| 409 | ], |
| 410 | }, |
| 411 | }); |
| 412 | assert.equal(result.isError, true, "a missing answer must fail the whole call"); |
| 413 | const { error } = payload(result); |
| 414 | assert.equal(error.kind, "malformed_response"); |
| 415 | assert.match(error.message, /second/); |
| 416 | }); |
| 417 | } finally { |
| 418 | await mock.close(); |
| 419 | } |
| 420 | }); |
| 421 | |
| 422 | test("every judgment tool reports the round-trip latency", async () => { |
| 423 | const mock = await startMock(); |
| 424 | try { |
| 425 | await withClient({ baseUrl: mock.url }, async (client) => { |
| 426 | const result = await client.callTool({ name: "jev_check", arguments: { state: "hello", question: "Greeting?" } }); |
| 427 | assert.equal(result.isError, undefined); |
| 428 | const body = payload(result); |
| 429 | assert.equal(typeof body.latency_ms, "number"); |
| 430 | assert.ok(body.latency_ms >= 0); |
| 431 | }); |
| 432 | } finally { |
| 433 | await mock.close(); |
| 434 | } |
| 435 | }); |