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

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

Keyboard shortcuts