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

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

Keyboard shortcuts