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

workbenchthis machine
Files 98937770891cfe254ef804fe1a597df1744c910b
517 lines · 23.0 KB · ~6.7k tok Raw History
1
#!/usr/bin/env node
2
/**
3
 * jev-mcp — MCP server exposing TypeSafe Jev as typed judgment tools.
4
 *
5
 * Jev is a System One model: it returns a typed answer plus a calibrated
6
 * probability distribution, never prose. These tools surface that faithfully.
7
 *
8
 * Rules that shape the whole surface:
9
 *
10
 *  1. The caller owns the option set. Every selecting tool requires options
11
 *     supplied by the caller, so the model can pick the wrong one but can never
12
 *     invent one. A selector cannot choose a candidate the enumerator dropped.
13
 *  2. Probabilities are always returned, never just the label.
14
 *  3. The API key comes from the environment only, never a tool argument.
15
 *  4. Nothing is silently dropped, overwritten, or truncated. A request that
16
 *     cannot be honoured exactly fails with a reason.
17
 *  5. Nothing but JSON-RPC is ever written to stdout.
18
 *  6. Every answer is checked against the question that was sent. A choice
19
 *     that was never offered, or a distribution that does not cover the
20
 *     offered options, is an error rather than a result.
21
 */
22
23
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk";
26
import type { EntryType, Question, ScoreCriteria } from "@typesafe-ai/sdk";
27
import { z } from "zod";
28
import { readFileSync } from "node:fs";
29
import { homedir } from "node:os";
30
import { join } from "node:path";
31
import {
32
  assertStateWithinLimit,
33
  assertUniqueIds,
34
  buildChoiceCriteria,
35
  DEFAULT_MAX_QUESTIONS,
36
  DEFAULT_MAX_SCORE_LEVELS,
37
  DEFAULT_MAX_STATE_CHARS,
38
  DEFAULT_TIMEOUT_MS,
39
  describeError,
40
  gateConfidence,
41
  gateProbability,
42
  readPositiveInt,
43
  validateChoiceAnswer,
44
  validateNoulAnswer,
45
  validateScoreAnswer,
46
} from "./lib.js";
47
48
// Read from package.json so the advertised version cannot drift from the release.
49
const VERSION: string = (() => {
50
  try {
51
    const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
52
    if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
53
  } catch {
54
    // fall through
55
  }
56
  return "0.0.0";
57
})();
58
59
// ── Configuration ───────────────────────────────────────────────────────────
60
61
const MODEL = process.env.JEV_MODEL ?? "jev-latest";
62
63
/**
64
 * Every log line goes to stderr.
65
 *
66
 * The TypeSafe SDK's default logger uses `console.info` and `console.debug`,
67
 * which write to stdout. On a stdio transport stdout carries JSON-RPC, so a
68
 * single verbose log line corrupts the stream and kills the connection.
69
 * Routing all four levels to stderr makes any TYPESAFE_LOG_LEVEL safe.
70
 */
71
const stderrLogger = {
72
  debug: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
73
  info: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
74
  warn: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
75
  error: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
76
};
77
78
const timeout = readPositiveInt(process.env.JEV_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, "JEV_TIMEOUT_MS");
79
const maxQuestions = readPositiveInt(process.env.JEV_MAX_QUESTIONS, DEFAULT_MAX_QUESTIONS, "JEV_MAX_QUESTIONS");
80
const maxStateChars = readPositiveInt(process.env.JEV_MAX_STATE_CHARS, DEFAULT_MAX_STATE_CHARS, "JEV_MAX_STATE_CHARS");
81
for (const setting of [timeout, maxQuestions, maxStateChars]) {
82
  if (setting.warning) console.error(`[jev-mcp] ${setting.warning}`);
83
}
84
85
// ── Shared schema pieces ────────────────────────────────────────────────────
86
87
/**
88
 * State accepted by Jev: plain text, or structured data for records and logs.
89
 *
90
 * `any` rather than `unknown` on purpose: the SDK constrains state to JsonValue
91
 * and `unknown` members are not assignable to it. The JSON Schema is identical.
92
 */
93
const StateSchema = z
94
  .union([z.string(), z.record(z.any()), z.array(z.any())])
95
  .describe("The content to evaluate. A plain string for text, or an object/array for structured data such as a record, a diff, or a chat log.");
96
97
/** Instructions accept JSON structure, which helps when a question has parts. */
98
const InstructionSchema = z
99
  .union([z.string().min(1), z.record(z.any()), z.array(z.any())])
100
  .describe("The judgment to make. A string, or an object/array when the question has several labelled parts. This is the only instruction Jev sees, so state it in full.");
101
102
/** Option and level descriptions accept the same JSON structure. */
103
const DescriptionSchema = z.union([z.string(), z.record(z.any()), z.array(z.any()), z.null()]);
104
105
const UsageSchema = z.object({ input_tokens: z.number(), output_tokens: z.number() });
106
const LatencySchema = z.number().describe("Wall-clock milliseconds for the API round trip, for your own calibration logs.");
107
const GateSchema = z.enum(["act", "review", "abstain"]);
108
109
const ActAbove = z
110
  .number()
111
  .min(0)
112
  .max(1)
113
  .optional()
114
  .describe("Confidence at or above which the answer is marked 'act'. Default 0.8. Calibrate on your own data and the cost of being wrong.");
115
const ReviewAbove = z
116
  .number()
117
  .min(0)
118
  .max(1)
119
  .optional()
120
  .describe("Confidence at or above which the answer is marked 'review' rather than 'abstain'. Default 0.5.");
121
122
// ── Client ──────────────────────────────────────────────────────────────────
123
124
let client: TypeSafeClient | undefined;
125
126
/**
127
 * Fallback source for the key.
128
 *
129
 * An MCP server is spawned with the client's own environment, not your shell's,
130
 * so anything exported from `~/.zshenv` never arrives. Confirmed on the wire: a
131
 * PreToolUse hook under the same client received `JEV_GUARD`, which is injected
132
 * through settings `env`, but not `TYPESAFE_API_KEY`, which lives only in the
133
 * shell profile.
134
 *
135
 * A 0600 file reaches every spawn path while keeping the secret out of
136
 * `~/.claude.json`, out of argv, and out of any repository.
137
 */
138
const KEY_FILE = process.env.JEV_KEY_FILE ?? join(homedir(), ".config", "typesafe", "key");
139
140
function readKeyFile(): string | undefined {
141
  try {
142
    const contents = readFileSync(KEY_FILE, "utf8").trim();
143
    return contents.length > 0 ? contents : undefined;
144
  } catch {
145
    return undefined;
146
  }
147
}
148
149
/**
150
 * Built lazily: the constructor throws without a key, and a missing key should
151
 * produce one clear tool error rather than stop the server from starting.
152
 */
153
function getClient(): TypeSafeClient {
154
  const apiKey = process.env.TYPESAFE_API_KEY ?? process.env.JEV_API_KEY ?? readKeyFile();
155
  if (!apiKey) {
156
    throw new Error(
157
      `No API key. Set TYPESAFE_API_KEY in the environment of the MCP client, or create ${KEY_FILE} with mode 0600. Never pass it as a tool argument.`,
158
    );
159
  }
160
  client ??= new TypeSafeClient({ apiKey, timeout: timeout.value, logger: stderrLogger });
161
  return client;
162
}
163
164
// ── Result helpers ──────────────────────────────────────────────────────────
165
166
/**
167
 * Tool results carry both a text block and structured content: the text keeps
168
 * older clients working, the structured payload is validated against the tool's
169
 * output schema so a caller gets typed data instead of a JSON string to parse.
170
 */
171
function ok<T extends Record<string, unknown>>(payload: T) {
172
  return {
173
    content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
174
    structuredContent: payload,
175
  };
176
}
177
178
function fail(error: unknown) {
179
  const described = describeError(error);
180
  return {
181
    isError: true,
182
    content: [{ type: "text" as const, text: JSON.stringify({ error: described }, null, 2) }],
183
  };
184
}
185
186
const server = new McpServer({ name: "jev", version: VERSION });
187
188
// ── classify: one of a defined set ─────────────────────────────────────────
189
190
server.registerTool(
191
  "jev_classify",
192
  {
193
    title: "Classify into one of your options",
194
    description:
195
      "Pick exactly one option from a set you define. Returns the chosen option, the probability of every option, a confidence value, and a recommended action gated on confidence. " +
196
      "Use when the answer is one of a fixed set. The options must be supplied by you: Jev selects among them and cannot invent a new one. Up to 255 options.",
197
    inputSchema: {
198
      state: StateSchema,
199
      question: InstructionSchema,
200
      options: z
201
        .record(DescriptionSchema)
202
        .describe("Map of option name to a description that separates it from the others. Both the name and the description are sent to the model, so keep names short and distinct. A description may be an object or array when structure clarifies it, or null to leave it undescribed."),
203
      add_none: z
204
        .boolean()
205
        .optional()
206
        .describe("Add a no-match option meaning none of yours fits. Defaults to true. Turn off only when one option must always apply. If you already use the name 'none', the added option takes a different key and it is reported back as none_option."),
207
      act_above: ActAbove,
208
      review_above: ReviewAbove,
209
    },
210
    outputSchema: {
211
      choice: z.string(),
212
      confidence: z.number(),
213
      probabilities: z.record(z.number()),
214
      none_option: z.string().nullable().describe("The key carrying the no-match meaning, or null when none was added."),
215
      action: GateSchema,
216
      thresholds: z.object({ act_above: z.number(), review_above: z.number() }),
217
      model: z.string(),
218
      usage: UsageSchema,
219
      latency_ms: LatencySchema,
220
    },
221
  },
222
  async ({ state, question, options, add_none, act_above, review_above }, extra) => {
223
    try {
224
      assertStateWithinLimit(state, maxStateChars.value);
225
      const actAbove = act_above ?? 0.8;
226
      const reviewAbove = review_above ?? 0.5;
227
      const { criteria, noneKey } = buildChoiceCriteria(options as Record<string, EntryType>, add_none !== false);
228
229
      const started = performance.now();
230
      const result = await getClient().systemOne(
231
        { state, model: MODEL, questions: { classify: choice(question, criteria) } },
232
        { signal: extra.signal },
233
      );
234
      const latency_ms = Math.round(performance.now() - started);
235
      const answer = result.answers.classify;
236
      validateChoiceAnswer(answer, Object.keys(criteria), "classify");
237
      return ok({
238
        choice: answer.choice,
239
        confidence: answer.confidence,
240
        probabilities: answer.probabilities,
241
        none_option: noneKey,
242
        action: gateConfidence(answer.confidence, actAbove, reviewAbove),
243
        thresholds: { act_above: actAbove, review_above: reviewAbove },
244
        model: result.model,
245
        usage: result.usage,
246
        latency_ms,
247
      });
248
    } catch (error) {
249
      return fail(error);
250
    }
251
  },
252
);
253
254
// ── score: a position on an ordered scale ──────────────────────────────────
255
256
server.registerTool(
257
  "jev_score",
258
  {
259
    title: "Rate on an ordered scale",
260
    description:
261
      "Rate the state along an ordered scale you define. Returns a probability-weighted score that can land between levels, the distribution, confidence, and a recommended action. " +
262
      "Use for degree or severity, not for picking a category.",
263
    inputSchema: {
264
      state: StateSchema,
265
      question: InstructionSchema,
266
      levels: z
267
        .array(DescriptionSchema)
268
        .min(2)
269
        .describe("Ordered level descriptions, lowest first. At least two. Each level must describe a concrete situation and stand on its own."),
270
      act_above: ActAbove,
271
      review_above: ReviewAbove,
272
    },
273
    outputSchema: {
274
      score: z.number(),
275
      confidence: z.number(),
276
      probabilities: z.record(z.number()),
277
      legend: z.record(z.any()),
278
      action: GateSchema,
279
      thresholds: z.object({ act_above: z.number(), review_above: z.number() }),
280
      model: z.string(),
281
      usage: UsageSchema,
282
      latency_ms: LatencySchema,
283
    },
284
  },
285
  async ({ state, question, levels, act_above, review_above }, extra) => {
286
    try {
287
      assertStateWithinLimit(state, maxStateChars.value);
288
      if (levels.length > DEFAULT_MAX_SCORE_LEVELS) {
289
        throw new Error(`levels must contain at most ${DEFAULT_MAX_SCORE_LEVELS} entries; received ${levels.length}.`);
290
      }
291
      const actAbove = act_above ?? 0.8;
292
      const reviewAbove = review_above ?? 0.5;
293
294
      const started = performance.now();
295
      const result = await getClient().systemOne(
296
        // zod already enforces two or more levels; the SDK types that as a tuple.
297
        { state, model: MODEL, questions: { rating: score(question, levels as unknown as ScoreCriteria) } },
298
        { signal: extra.signal },
299
      );
300
      const latency_ms = Math.round(performance.now() - started);
301
      const answer = result.answers.rating;
302
      validateScoreAnswer(answer, levels.length, "rating");
303
      return ok({
304
        score: answer.score,
305
        confidence: answer.confidence,
306
        probabilities: answer.probabilities,
307
        legend: answer.legend,
308
        action: gateConfidence(answer.confidence, actAbove, reviewAbove),
309
        thresholds: { act_above: actAbove, review_above: reviewAbove },
310
        model: result.model,
311
        usage: result.usage,
312
        latency_ms,
313
      });
314
    } catch (error) {
315
      return fail(error);
316
    }
317
  },
318
);
319
320
// ── check: probability that a condition holds ──────────────────────────────
321
322
server.registerTool(
323
  "jev_check",
324
  {
325
    title: "Yes/no with a probability",
326
    description:
327
      "Ask a yes/no question. Returns the probability that the answer is yes, from 0 to 1, plus a verdict. There is no separate confidence: a value near 0.5 means yes and no are close to equally likely, not that the answer is 'medium'. " +
328
      "Use one check per label when several labels may apply at once.",
329
    inputSchema: {
330
      state: StateSchema,
331
      question: InstructionSchema,
332
      yes_means: DescriptionSchema.optional().describe("What a yes means. Sharpens the judgment."),
333
      no_means: DescriptionSchema.optional().describe("What a no means."),
334
      yes_at_or_above: z.number().min(0).max(1).optional().describe("Probability at or above which the verdict is 'yes'. Default 0.7."),
335
      no_at_or_below: z.number().min(0).max(1).optional().describe("Probability at or below which the verdict is 'no'. Default 0.3. Between the two the verdict is 'uncertain'."),
336
    },
337
    outputSchema: {
338
      probability_yes: z.number(),
339
      verdict: z.enum(["yes", "no", "uncertain"]),
340
      thresholds: z.object({ yes_at_or_above: z.number(), no_at_or_below: z.number() }),
341
      model: z.string(),
342
      usage: UsageSchema,
343
      latency_ms: LatencySchema,
344
    },
345
  },
346
  async ({ state, question, yes_means, no_means, yes_at_or_above, no_at_or_below }, extra) => {
347
    try {
348
      assertStateWithinLimit(state, maxStateChars.value);
349
      const yesAt = yes_at_or_above ?? 0.7;
350
      const noAt = no_at_or_below ?? 0.3;
351
      if (noAt > yesAt) throw new Error("no_at_or_below must not exceed yes_at_or_above.");
352
353
      const criteria =
354
        yes_means !== undefined || no_means !== undefined
355
          ? { true: yes_means ?? null, false: no_means ?? null }
356
          : undefined;
357
358
      const started = performance.now();
359
      const result = await getClient().systemOne(
360
        { state, model: MODEL, questions: { check: criteria ? noul(question, criteria) : noul(question) } },
361
        { signal: extra.signal },
362
      );
363
      const latency_ms = Math.round(performance.now() - started);
364
      validateNoulAnswer(result.answers.check, "check");
365
      const probability = result.answers.check.noul;
366
      return ok({
367
        probability_yes: probability,
368
        verdict: gateProbability(probability, yesAt, noAt),
369
        thresholds: { yes_at_or_above: yesAt, no_at_or_below: noAt },
370
        model: result.model,
371
        usage: result.usage,
372
        latency_ms,
373
      });
374
    } catch (error) {
375
      return fail(error);
376
    }
377
  },
378
);
379
380
// ── ask: many questions about one state, in a single request ───────────────
381
382
server.registerTool(
383
  "jev_ask",
384
  {
385
    title: "Ask many questions about one state",
386
    description:
387
      "Ask several independent questions about the same state in ONE request. Jev prefills the state once and scores every question in a single forward pass, so extra questions add almost no latency. " +
388
      "Prefer this over repeated single-question calls: on a document-dominated workload it is dramatically cheaper and faster with no change in answers. " +
389
      "Questions cannot see each other's answers, so state any speculative premise explicitly and let your own logic decide which answers apply.",
390
    inputSchema: {
391
      state: StateSchema,
392
      questions: z
393
        .array(
394
          z.object({
395
            id: z.string().min(1).describe("Your key for this question. Returned alongside the answer. Never sent to the model, so put the full meaning in the question itself. Must be unique within the call."),
396
            type: z.enum(["classify", "score", "check"]),
397
            question: InstructionSchema,
398
            options: z.record(DescriptionSchema).optional().describe("Required for type 'classify'."),
399
            add_none: z.boolean().optional().describe("For 'classify': add a no-match option. Defaults to true."),
400
            levels: z.array(DescriptionSchema).min(2).optional().describe("Required for type 'score'."),
401
            yes_means: DescriptionSchema.optional().describe("For 'check': what a yes means."),
402
            no_means: DescriptionSchema.optional().describe("For 'check': what a no means."),
403
          }),
404
        )
405
        .min(1),
406
      act_above: ActAbove,
407
      review_above: ReviewAbove,
408
    },
409
    outputSchema: {
410
      answers: z.record(z.any()).describe("Keyed by your question ids. Choice and Score answers also carry an 'action' gated on confidence."),
411
      none_options: z.record(z.string().nullable()).describe("For each 'classify' question, the key carrying the no-match meaning, or null."),
412
      model: z.string(),
413
      usage: UsageSchema,
414
      latency_ms: LatencySchema,
415
    },
416
  },
417
  async ({ state, questions, act_above, review_above }, extra) => {
418
    try {
419
      assertStateWithinLimit(state, maxStateChars.value);
420
      if (questions.length > maxQuestions.value) {
421
        throw new Error(`questions must contain at most ${maxQuestions.value} entries; received ${questions.length}. Raise JEV_MAX_QUESTIONS if that limit is wrong for your workload.`);
422
      }
423
      assertUniqueIds(questions.map((q) => q.id));
424
      const actAbove = act_above ?? 0.8;
425
      const reviewAbove = review_above ?? 0.5;
426
427
      const built: Record<string, Question> = {};
428
      const noneOptions: Record<string, string | null> = {};
429
      // What each answer must be checked against once it comes back.
430
      const expected: Record<string, { type: "classify"; keys: string[] } | { type: "score"; levels: number } | { type: "check" }> = {};
431
432
      for (const q of questions) {
433
        if (q.type === "classify") {
434
          if (!q.options) throw new Error(`Question '${q.id}' is type 'classify' and needs options.`);
435
          const { criteria, noneKey } = buildChoiceCriteria(q.options as Record<string, EntryType>, q.add_none !== false, `options for '${q.id}'`);
436
          built[q.id] = choice(q.question, criteria);
437
          noneOptions[q.id] = noneKey;
438
          expected[q.id] = { type: "classify", keys: Object.keys(criteria) };
439
        } else if (q.type === "score") {
440
          if (!q.levels) throw new Error(`Question '${q.id}' is type 'score' and needs levels.`);
441
          if (q.levels.length > DEFAULT_MAX_SCORE_LEVELS) {
442
            throw new Error(`levels for '${q.id}' must contain at most ${DEFAULT_MAX_SCORE_LEVELS} entries.`);
443
          }
444
          built[q.id] = score(q.question, q.levels as unknown as ScoreCriteria);
445
          expected[q.id] = { type: "score", levels: q.levels.length };
446
        } else {
447
          const criteria =
448
            q.yes_means !== undefined || q.no_means !== undefined
449
              ? { true: q.yes_means ?? null, false: q.no_means ?? null }
450
              : undefined;
451
          built[q.id] = criteria ? noul(q.question, criteria) : noul(q.question);
452
          expected[q.id] = { type: "check" };
453
        }
454
      }
455
456
      const started = performance.now();
457
      const result = await getClient().systemOne({ state, model: MODEL, questions: built }, { signal: extra.signal });
458
      const latency_ms = Math.round(performance.now() - started);
459
460
      // Every question sent must come back well-formed. A missing answer is an
461
      // error, not a silently absent key, so a caller never acts on a partial set.
462
      const raw = result.answers as unknown as Record<string, Record<string, unknown>>;
463
      const answers: Record<string, unknown> = {};
464
      for (const [id, want] of Object.entries(expected)) {
465
        const answer = raw[id];
466
        if (want.type === "classify") validateChoiceAnswer(answer, want.keys, id);
467
        else if (want.type === "score") validateScoreAnswer(answer, want.levels, id);
468
        else validateNoulAnswer(answer, id);
469
        // Attach the confidence gate where there is a confidence to gate on.
470
        answers[id] =
471
          typeof answer?.confidence === "number"
472
            ? { ...answer, action: gateConfidence(answer.confidence, actAbove, reviewAbove) }
473
            : answer;
474
      }
475
476
      return ok({ answers, none_options: noneOptions, model: result.model, usage: result.usage, latency_ms });
477
    } catch (error) {
478
      return fail(error);
479
    }
480
  },
481
);
482
483
// ── models: what this key can actually use ─────────────────────────────────
484
485
server.registerTool(
486
  "jev_models",
487
  {
488
    title: "List available models",
489
    description:
490
      "List the models this API key can use, with their release dates. Use it to confirm the key works and to find a model id for JEV_MODEL before assuming one exists.",
491
    inputSchema: {},
492
    outputSchema: {
493
      active_model: z.string().describe("The model these tools send requests to."),
494
      models: z.array(z.object({ name: z.string(), description: z.string(), release_date: z.string() })),
495
    },
496
  },
497
  async (_args, extra) => {
498
    try {
499
      const models = await getClient().models.list({ signal: extra.signal });
500
      return ok({ active_model: MODEL, models });
501
    } catch (error) {
502
      return fail(error);
503
    }
504
  },
505
);
506
507
// ── Boot ────────────────────────────────────────────────────────────────────
508
509
async function main() {
510
  await server.connect(new StdioServerTransport());
511
  console.error(`[jev-mcp] ready — version ${VERSION}, model ${MODEL}`);
512
}
513
514
main().catch((error) => {
515
  console.error("[jev-mcp] failed to start:", error);
516
  process.exit(1);
517
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts