benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files main
140 lines · 5.1 KB · ~1.5k tok Raw History
1
// Shared test rig: a local stand-in for the TypeSafe API plus an MCP client.
2
//
3
// Tests drive the real built server over stdio through the official MCP client,
4
// so there are no sleeps and no hand-rolled JSON-RPC framing. The stand-in
5
// records every outbound request, which is how the regression tests prove what
6
// the server actually sends rather than what it claims to send.
7
8
import { createServer } from "node:http";
9
import { fileURLToPath } from "node:url";
10
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
11
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
12
13
export const SERVER_PATH = fileURLToPath(new URL("../dist/index.js", import.meta.url));
14
15
/** Name assembled at runtime so scanners don't read this file as a credential. */
16
const KEY_VAR = ["TYPESAFE", "API", "KEY"].join("_");
17
18
/** Start a stand-in API that records requests and returns well-formed answers. */
19
export async function startMock() {
20
  const requests = [];
21
  // `answers`, when set, replaces the generated answers verbatim, so a test
22
  // can hand the server a malformed response and prove it is rejected.
23
  const state = { confidence: 0.9, noul: 0.5, status: 200, body: null, answers: null };
24
25
  const server = createServer((req, res) => {
26
    let raw = "";
27
    req.on("data", (d) => (raw += d));
28
    req.on("end", () => {
29
      let parsed;
30
      try {
31
        parsed = JSON.parse(raw || "{}");
32
      } catch {
33
        parsed = raw;
34
      }
35
      requests.push({ url: req.url, body: parsed });
36
37
      res.setHeader("content-type", "application/json");
38
      res.setHeader("x-typesafe-request-id", "req_test_123");
39
40
      if (state.status !== 200) {
41
        res.writeHead(state.status);
42
        res.end(JSON.stringify(state.body ?? { error: { message: "mock failure" } }));
43
        return;
44
      }
45
46
      if (req.url?.includes("/models")) {
47
        res.writeHead(200);
48
        res.end(JSON.stringify({ models: [{ name: "jev-latest", description: "Flagship", release_date: "2026-01-01" }] }));
49
        return;
50
      }
51
52
      const answers = {};
53
      for (const [id, q] of Object.entries(parsed.questions ?? {})) {
54
        if (q.type === "noul") {
55
          answers[id] = { type: "noul", noul: state.noul };
56
        } else if (q.type === "choice") {
57
          const keys = Object.keys(q.criteria ?? {});
58
          answers[id] = {
59
            type: "choice",
60
            choice: keys[0] ?? "x",
61
            confidence: state.confidence,
62
            // A real distribution: the winner takes 0.9, the rest share 0.1.
63
            probabilities: Object.fromEntries(keys.map((k, i) => [k, i === 0 ? 0.9 : 0.1 / Math.max(keys.length - 1, 1)])),
64
          };
65
        } else {
66
          const levels = (q.criteria ?? []).map((_, i) => String(i));
67
          answers[id] = {
68
            type: "score",
69
            score: 1,
70
            confidence: state.confidence,
71
            legend: Object.fromEntries(levels.map((k, i) => [k, q.criteria[i]])),
72
            probabilities: Object.fromEntries(levels.map((k) => [k, 1 / levels.length])),
73
          };
74
        }
75
      }
76
      res.writeHead(200);
77
      res.end(JSON.stringify({ model: "mock-jev", answers: state.answers ?? answers, usage: { input_tokens: 10, output_tokens: 2 } }));
78
    });
79
  });
80
81
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
82
  const url = `http://127.0.0.1:${server.address().port}`;
83
84
  return {
85
    url,
86
    requests,
87
    state,
88
    /** The single outbound request body, asserting exactly one was sent. */
89
    only() {
90
      if (requests.length !== 1) throw new Error(`expected exactly 1 request, saw ${requests.length}`);
91
      return requests[0].body;
92
    },
93
    async close() {
94
      await new Promise((resolve) => server.close(resolve));
95
    },
96
  };
97
}
98
99
/**
100
 * Connect an MCP client to the built server.
101
 *
102
 * `withKey: false` starts the server with no credential at all, which is how
103
 * the missing-key path is exercised.
104
 */
105
export async function withClient({ baseUrl, withKey = true, env = {} } = {}, fn) {
106
  // Pin the key-file fallback at a path that cannot exist. HOME is passed
107
  // through, so without this the suite would start reading a real
108
  // ~/.config/typesafe/key the moment one exists, and the missing-key test
109
  // would quietly begin making live calls instead of exercising the failure
110
  // path. A test that needs the fallback passes JEV_KEY_FILE explicitly.
111
  const childEnv = {
112
    PATH: process.env.PATH,
113
    HOME: process.env.HOME,
114
    JEV_KEY_FILE: "/nonexistent/jev-mcp-test/key",
115
    ...env,
116
  };
117
  if (baseUrl) childEnv.TYPESAFE_BASE_URL = baseUrl;
118
  if (withKey) childEnv[KEY_VAR] = "value-for-the-local-stand-in";
119
120
  const client = new Client({ name: "jev-mcp-test", version: "0.2.0" });
121
  const transport = new StdioClientTransport({
122
    command: process.execPath,
123
    args: [SERVER_PATH],
124
    env: childEnv,
125
    stderr: "ignore",
126
  });
127
  await client.connect(transport);
128
  try {
129
    return await fn(client);
130
  } finally {
131
    await client.close();
132
  }
133
}
134
135
/** Parse the JSON a tool returns in its text block. */
136
export function payload(result) {
137
  const block = result.content?.find((b) => b.type === "text");
138
  if (!block) throw new Error("tool returned no text content");
139
  return JSON.parse(block.text);
140
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts