jev-agent-tools 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
package/src/jev/client.ts CHANGED
@@ -232,10 +232,19 @@ export function createJevClient(
232
232
  options.onUsage?.(next.usage);
233
233
  }
234
234
  };
235
- const missing = (ids: readonly string[], reason: string) => {
235
+ let stoppedCause: Extract<Answer, { type: "unjudged" }>["cause"];
236
+ const missing = (
237
+ ids: readonly string[],
238
+ reason: string,
239
+ cause: Extract<Answer, { type: "unjudged" }>["cause"] = stoppedCause,
240
+ ) => {
236
241
  const batchAnswers = Object.fromEntries(
237
- ids.map((id) => [id, { type: "unjudged" as const, reason }]),
242
+ ids.map((id) => [
243
+ id,
244
+ { type: "unjudged" as const, reason, ...(cause ? { cause } : {}) },
245
+ ]),
238
246
  );
247
+ if (cause) meta.failureCause ??= cause;
239
248
  for (const [id, answer] of Object.entries(batchAnswers))
240
249
  if (!witnessSet.has(id) || !answers[id]) answers[id] = answer;
241
250
  meta.batches?.push({ questionIds: [...ids], answers: batchAnswers });
@@ -259,6 +268,7 @@ export function createJevClient(
259
268
  });
260
269
  for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
261
270
  let release: (() => void) | undefined;
271
+ let settle: (() => void) | undefined;
262
272
  const timeoutCancellation = new AbortController();
263
273
  let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
264
274
  try {
@@ -267,12 +277,20 @@ export function createJevClient(
267
277
  missing(ids, stopped);
268
278
  return;
269
279
  }
280
+ // The reservation is held from here on; `finally` releases it on
281
+ // every exit path (stopped, refused, failed, aborted, answered).
282
+ settle = await options.awaitAdmission?.(options.signal);
283
+ if (stopped) {
284
+ missing(ids, stopped);
285
+ return;
286
+ }
270
287
  const admission = options.beforeRequest?.(ids.length);
271
288
  if (admission && !admission.ok) {
272
289
  // A diagnostic denied admission supplies no judgment batch and
273
290
  // must not replace the token refusal with a budget reason.
274
291
  if (diagnostic) return;
275
292
  stopped = admission.error;
293
+ stoppedCause = options.admissionCause?.();
276
294
  missing(ids, stopped);
277
295
  return;
278
296
  }
@@ -302,6 +320,9 @@ export function createJevClient(
302
320
  body = undefined;
303
321
  }
304
322
  addMetadata(body);
323
+ // Release the USD gate before any subdivision re-enters send().
324
+ settle?.();
325
+ settle = undefined;
305
326
  release();
306
327
  release = undefined;
307
328
  if (
@@ -311,12 +332,14 @@ export function createJevClient(
311
332
  if (diagnostic) {
312
333
  stopped =
313
334
  "Jev max_tokens_exceeded: state too large for a single question; remaining questions not judged.";
335
+ stoppedCause = "provider_context_refusal";
314
336
  return;
315
337
  }
316
338
  if (ids.length === 1) {
317
339
  missing(
318
340
  ids,
319
341
  "Jev max_tokens_exceeded: state too large for a single question; question not judged.",
342
+ "provider_context_refusal",
320
343
  );
321
344
  return;
322
345
  }
@@ -346,7 +369,7 @@ export function createJevClient(
346
369
  const split = splitGroups(groups);
347
370
  if (split.ok)
348
371
  await Promise.all(split.halves.map((half) => send(half)));
349
- else missing(ids, split.error);
372
+ else missing(ids, split.error, "provider_context_refusal");
350
373
  return;
351
374
  }
352
375
  if (response.ok && isRecord(body)) {
@@ -355,7 +378,12 @@ export function createJevClient(
355
378
  const batchAnswers = Object.fromEntries(
356
379
  ids.map((id) => [
357
380
  id,
358
- normalize(raw[id], selected[id] as Question),
381
+ (() => {
382
+ const answer = normalize(raw[id], selected[id] as Question);
383
+ return answer.type === "unjudged"
384
+ ? { ...answer, cause: "invalid_response" as const }
385
+ : { ...answer, source: "fresh" as const };
386
+ })(),
359
387
  ]),
360
388
  );
361
389
  meta.batches?.push({ questionIds: ids, answers: batchAnswers });
@@ -405,7 +433,7 @@ export function createJevClient(
405
433
  ) {
406
434
  const reason = `Jev HTTP 429, retry after ${retryAfter} s`;
407
435
  failure ??= reason;
408
- missing(ids, reason);
436
+ missing(ids, reason, "service_unavailable");
409
437
  return;
410
438
  }
411
439
  const reason = response.ok
@@ -422,7 +450,11 @@ export function createJevClient(
422
450
  failure ??= reason;
423
451
  if (response.status === 401 || response.status === 403)
424
452
  stopped = reason;
425
- missing(ids, reason);
453
+ missing(
454
+ ids,
455
+ reason,
456
+ response.ok ? "invalid_response" : "service_unavailable",
457
+ );
426
458
  return;
427
459
  }
428
460
  if (Number.isFinite(retryAfter) && retryAfter >= 0)
@@ -434,10 +466,15 @@ export function createJevClient(
434
466
  const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
435
467
  if (options.signal?.aborted || attempt === REQUEST_ATTEMPTS - 1) {
436
468
  failure ??= reason;
437
- missing(ids, reason);
469
+ missing(
470
+ ids,
471
+ reason,
472
+ options.signal?.aborted ? "cancelled" : "transport_failure",
473
+ );
438
474
  return;
439
475
  }
440
476
  } finally {
477
+ settle?.();
441
478
  release?.();
442
479
  timeoutCancellation.abort();
443
480
  }
@@ -446,7 +483,11 @@ export function createJevClient(
446
483
  } catch (error) {
447
484
  const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
448
485
  failure ??= reason;
449
- missing(ids, reason);
486
+ missing(
487
+ ids,
488
+ reason,
489
+ options.signal?.aborted ? "cancelled" : "transport_failure",
490
+ );
450
491
  return;
451
492
  }
452
493
  }
@@ -461,13 +502,19 @@ export function createJevClient(
461
502
  )
462
503
  return true;
463
504
  for (const id of group) {
464
- answers[id] = structuredClone(
465
- cache.get(keys.get(id) as string) as Answer,
466
- );
505
+ answers[id] = {
506
+ ...structuredClone(cache.get(keys.get(id) as string) as Answer),
507
+ source: "cache",
508
+ };
467
509
  meta.cacheHits = (meta.cacheHits ?? 0) + 1;
468
510
  }
469
- const observedWitnesses = structuredClone(
470
- witnessCache.get(witnessKey(group)) ?? {},
511
+ const observedWitnesses = Object.fromEntries(
512
+ Object.entries(
513
+ structuredClone(witnessCache.get(witnessKey(group)) ?? {}),
514
+ ).map(([id, answer]) => [
515
+ id,
516
+ { ...answer, source: "cache" as const },
517
+ ]),
471
518
  );
472
519
  for (const [id, answer] of Object.entries(observedWitnesses)) {
473
520
  if (!answers[id]) {
@@ -483,9 +530,12 @@ export function createJevClient(
483
530
  id,
484
531
  witnessSet.has(id)
485
532
  ? (observedWitnesses[id] as Answer)
486
- : structuredClone(
487
- cache.get(keys.get(id) as string) as Answer,
488
- ),
533
+ : {
534
+ ...structuredClone(
535
+ cache.get(keys.get(id) as string) as Answer,
536
+ ),
537
+ source: "cache" as const,
538
+ },
489
539
  ]),
490
540
  ),
491
541
  });
package/src/jev/types.ts CHANGED
@@ -15,8 +15,19 @@ export type Question =
15
15
  }
16
16
  | { type: "choice"; instructions: string; criteria: Record<string, string> }
17
17
  | { type: "score"; instructions: string; criteria: string[] };
18
- export type Answer =
19
- | { type: "unjudged"; reason: string }
18
+ export type Answer = { source?: "fresh" | "cache" } & (
19
+ | {
20
+ type: "unjudged";
21
+ reason: string;
22
+ cause?:
23
+ | "call_budget"
24
+ | "session_budget"
25
+ | "provider_context_refusal"
26
+ | "invalid_response"
27
+ | "service_unavailable"
28
+ | "transport_failure"
29
+ | "cancelled";
30
+ }
20
31
  | { type: "bool"; p: number }
21
32
  | {
22
33
  type: "choice";
@@ -30,8 +41,10 @@ export type Answer =
30
41
  confidence: number;
31
42
  legend: Json;
32
43
  probabilities: Record<string, number>;
33
- };
44
+ }
45
+ );
34
46
  export interface JudgmentMetadata {
47
+ failureCause?: Extract<Answer, { type: "unjudged" }>["cause"];
35
48
  usage?: { inputTokens: number; costUsd: number };
36
49
  model?: string;
37
50
  calls?: number;
@@ -48,6 +61,14 @@ export interface JudgmentOptions {
48
61
  witnesses?: readonly string[];
49
62
  cache?: boolean;
50
63
  beforeRequest?: (questionCount: number) => Result<object>;
64
+ /** Structured cause of a denied beforeRequest admission, when established. */
65
+ admissionCause?: () => "call_budget" | "session_budget";
66
+ /**
67
+ * Awaited before each admission check. Resolves to a release function when
68
+ * it reserved a slot (a session under a USD limit), which the client calls
69
+ * exactly once on every exit path; rejects if `signal` aborts while waiting.
70
+ */
71
+ awaitAdmission?: (signal?: AbortSignal) => Promise<(() => void) | undefined>;
51
72
  onUsage?: (usage: { inputTokens: number; costUsd: number }) => void;
52
73
  }
53
74
  export interface JevClient {
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { canonicalPath } from "../adapters/canonical-path.ts";
6
+ import { MCP_SHUTDOWN_FLUSH_TIMEOUT_MS } from "../constants.ts";
7
+ import { type JsonRpcResponse, McpServer, PARSE_ERROR } from "./protocol.ts";
8
+ import { createMcpTools } from "./tools.ts";
9
+
10
+ const usage = `jev-agent-tools-mcp - MCP stdio server for the six jev_* tools
11
+
12
+ Usage: jev-agent-tools-mcp [--root <repository-directory>]
13
+
14
+ The repository directory is --root, else JEV_TOOLS_ROOT, else the current
15
+ directory. Configuration uses the same JEV_TOOLS_* environment variables as
16
+ the pi and omp extension. Protocol messages use stdout; diagnostics stderr.`;
17
+
18
+ function packageVersion(): string {
19
+ try {
20
+ // src/mcp/main.ts and dist/mcp/main.js both sit two levels below the root.
21
+ const text = readFileSync(
22
+ new URL("../../package.json", import.meta.url),
23
+ "utf8",
24
+ );
25
+ const parsed = JSON.parse(text) as { version?: unknown };
26
+ return typeof parsed.version === "string" ? parsed.version : "0.0.0";
27
+ } catch {
28
+ return "0.0.0";
29
+ }
30
+ }
31
+
32
+ function parseArgs(argv: readonly string[]): { root?: string; exit?: string } {
33
+ let root: string | undefined;
34
+ for (let index = 0; index < argv.length; index++) {
35
+ const arg = argv[index];
36
+ if (arg === "--help" || arg === "-h") return { exit: usage };
37
+ if (arg === "--version") return { exit: packageVersion() };
38
+ if (arg === "--root") {
39
+ root = argv[++index];
40
+ if (!root) throw new Error("--root requires a directory");
41
+ } else if (arg?.startsWith("--root=")) root = arg.slice("--root=".length);
42
+ else throw new Error(`Unknown argument: ${arg}\n\n${usage}`);
43
+ }
44
+ return { root };
45
+ }
46
+
47
+ async function main(): Promise<void> {
48
+ const parsed = parseArgs(process.argv.slice(2));
49
+ if (parsed.exit !== undefined) {
50
+ process.stdout.write(`${parsed.exit}\n`);
51
+ return;
52
+ }
53
+ const root = await canonicalPath(
54
+ resolve(parsed.root ?? process.env.JEV_TOOLS_ROOT ?? process.cwd()),
55
+ );
56
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
57
+ throw new Error(`Repository directory not found: ${root}`);
58
+ const { tools, instructions, configured, warning } = await createMcpTools({
59
+ root,
60
+ });
61
+ const server = new McpServer(
62
+ { name: "jev-agent-tools", version: packageVersion(), instructions },
63
+ tools,
64
+ );
65
+ if (warning) process.stderr.write(`jev-agent-tools MCP: ${warning}\n`);
66
+ process.stderr.write(
67
+ `jev-agent-tools MCP server ready (root ${root}; ${configured ? "endpoint configured" : "no endpoint: set JEV_TOOLS_URL and JEV_TOOLS_API_KEY, or save them with /jev-setup in pi or omp"})\n`,
68
+ );
69
+ let closing = false;
70
+ const send = (response: JsonRpcResponse | JsonRpcResponse[] | undefined) => {
71
+ if (
72
+ closing ||
73
+ response === undefined ||
74
+ (Array.isArray(response) && !response.length)
75
+ )
76
+ return;
77
+ // Newline-delimited JSON; JSON.stringify never emits raw newlines.
78
+ process.stdout.write(`${JSON.stringify(response)}\n`);
79
+ };
80
+ const pending = new Set<Promise<void>>();
81
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
82
+ const shutdown = async (code: number) => {
83
+ if (closing) return;
84
+ closing = true;
85
+ lines.close();
86
+ process.stdin.destroy();
87
+ server.abortAll();
88
+ // Tool promises include bounded process-tree escalation. Do not exit when
89
+ // only the direct shell has closed: descendants may still need SIGKILL.
90
+ await Promise.allSettled(pending);
91
+ if (process.stdout.destroyed) process.exit(code);
92
+ // A client may leave its stdout pipe open without draining it. Cleanup
93
+ // is already complete; never let that client's backpressure hold us alive.
94
+ setTimeout(() => process.exit(code), MCP_SHUTDOWN_FLUSH_TIMEOUT_MS);
95
+ process.stdout.end(() => process.exit(code));
96
+ };
97
+ process.on("SIGTERM", () => void shutdown(143));
98
+ process.on("SIGINT", () => void shutdown(130));
99
+ process.stdout.on("error", () => void shutdown(1));
100
+ lines.on("line", (line) => {
101
+ if (closing || !line.trim()) return;
102
+ let message: unknown;
103
+ try {
104
+ message = JSON.parse(line);
105
+ } catch {
106
+ send(server.error(null, PARSE_ERROR, "Parse error: invalid JSON."));
107
+ return;
108
+ }
109
+ const work = (async () => {
110
+ if (Array.isArray(message)) {
111
+ // JSON-RPC batches (protocol 2025-03-26) answer as one array.
112
+ const responses = await Promise.all(
113
+ message.map((item) => server.handle(item)),
114
+ );
115
+ send(
116
+ responses.filter(
117
+ (item): item is JsonRpcResponse => item !== undefined,
118
+ ),
119
+ );
120
+ } else send(await server.handle(message));
121
+ })().catch((error: unknown) => {
122
+ process.stderr.write(`jev-agent-tools MCP: ${String(error)}\n`);
123
+ });
124
+ pending.add(work);
125
+ void work.finally(() => pending.delete(work));
126
+ });
127
+ lines.on("close", () => void shutdown(0));
128
+ }
129
+
130
+ main().catch((error: unknown) => {
131
+ process.stderr.write(
132
+ `jev-agent-tools MCP: ${error instanceof Error ? error.message : String(error)}\n`,
133
+ );
134
+ process.exit(1);
135
+ });
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) server core: JSON-RPC 2.0 dispatch for
3
+ * the tools capability only. No I/O here; the stdio transport feeds it parsed
4
+ * messages and writes back whatever it returns.
5
+ *
6
+ * Supports the initialize-based protocol versions and the 2026-07-28
7
+ * `server/discover` entry point with per-request version metadata.
8
+ */
9
+
10
+ export const SUPPORTED_VERSIONS = [
11
+ "2026-07-28",
12
+ "2025-11-25",
13
+ "2025-06-18",
14
+ "2025-03-26",
15
+ "2024-11-05",
16
+ ] as const;
17
+ const LATEST_INITIALIZE_VERSION = "2025-11-25";
18
+ const VERSION_META = "io.modelcontextprotocol/protocolVersion";
19
+
20
+ export const PARSE_ERROR = -32700;
21
+ export const INVALID_REQUEST = -32600;
22
+ export const METHOD_NOT_FOUND = -32601;
23
+ export const INVALID_PARAMS = -32602;
24
+ export const INTERNAL_ERROR = -32603;
25
+ export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
26
+
27
+ /** Input-schema failure, distinct from semantic tool refusals and server faults. */
28
+ export class McpInvalidParams extends Error {}
29
+
30
+ export interface McpContent {
31
+ type: "text";
32
+ text: string;
33
+ }
34
+ export interface McpCallResult {
35
+ content: McpContent[];
36
+ isError?: boolean;
37
+ structuredContent?: Record<string, unknown>;
38
+ }
39
+ export interface McpTool {
40
+ name: string;
41
+ title?: string;
42
+ description: string;
43
+ inputSchema: { type: "object"; [key: string]: unknown };
44
+ outputSchema?: { type: "object"; [key: string]: unknown };
45
+ annotations?: {
46
+ title?: string;
47
+ readOnlyHint?: boolean;
48
+ destructiveHint?: boolean;
49
+ idempotentHint?: boolean;
50
+ openWorldHint?: boolean;
51
+ };
52
+ call(
53
+ args: Record<string, unknown>,
54
+ signal: AbortSignal,
55
+ ): Promise<McpCallResult>;
56
+ }
57
+ export interface McpServerInfo {
58
+ name: string;
59
+ version: string;
60
+ instructions?: string;
61
+ }
62
+
63
+ type Id = string | number;
64
+ export type JsonRpcResponse =
65
+ | { jsonrpc: "2.0"; id: Id | null; result: Record<string, unknown> }
66
+ | {
67
+ jsonrpc: "2.0";
68
+ id: Id | null;
69
+ error: { code: number; message: string; data?: unknown };
70
+ };
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+ function isId(value: unknown): value is Id {
76
+ return (
77
+ typeof value === "string" ||
78
+ (typeof value === "number" && Number.isFinite(value))
79
+ );
80
+ }
81
+
82
+ export class McpServer {
83
+ private readonly tools: Map<string, McpTool>;
84
+ private readonly info: McpServerInfo;
85
+ private readonly inFlight = new Map<Id, AbortController>();
86
+ private negotiatedVersion: string = LATEST_INITIALIZE_VERSION;
87
+
88
+ constructor(info: McpServerInfo, tools: readonly McpTool[]) {
89
+ this.info = info;
90
+ this.tools = new Map(tools.map((tool) => [tool.name, tool]));
91
+ }
92
+
93
+ /** Abort every running tool call, e.g. when stdin closes. */
94
+ abortAll(): void {
95
+ for (const controller of this.inFlight.values()) controller.abort();
96
+ this.inFlight.clear();
97
+ }
98
+
99
+ /** Handle one decoded JSON-RPC message; notifications return undefined. */
100
+ async handle(message: unknown): Promise<JsonRpcResponse | undefined> {
101
+ if (!isRecord(message) || message.jsonrpc !== "2.0")
102
+ return this.error(
103
+ isRecord(message) && isId(message.id) ? message.id : null,
104
+ INVALID_REQUEST,
105
+ "Invalid JSON-RPC 2.0 message.",
106
+ );
107
+ const { method, id } = message;
108
+ const params = isRecord(message.params) ? message.params : {};
109
+ // Responses to server-initiated requests: this server sends none.
110
+ if (method === undefined && ("result" in message || "error" in message))
111
+ return undefined;
112
+ if (typeof method !== "string")
113
+ return this.error(
114
+ isId(id) ? id : null,
115
+ INVALID_REQUEST,
116
+ "Missing method.",
117
+ );
118
+ if (id === undefined) {
119
+ this.notify(method, params);
120
+ return undefined;
121
+ }
122
+ if (!isId(id))
123
+ return this.error(
124
+ null,
125
+ INVALID_REQUEST,
126
+ "Request id must be a string or number.",
127
+ );
128
+ const meta = isRecord(params._meta) ? params._meta : {};
129
+ const requested = meta[VERSION_META];
130
+ if (
131
+ typeof requested === "string" &&
132
+ !(SUPPORTED_VERSIONS as readonly string[]).includes(requested)
133
+ )
134
+ return this.error(
135
+ id,
136
+ UNSUPPORTED_PROTOCOL_VERSION,
137
+ "Unsupported protocol version.",
138
+ {
139
+ supported: [...SUPPORTED_VERSIONS],
140
+ requested,
141
+ },
142
+ );
143
+ const version =
144
+ typeof requested === "string"
145
+ ? requested
146
+ : method === "server/discover"
147
+ ? "2026-07-28"
148
+ : this.negotiatedVersion;
149
+ try {
150
+ switch (method) {
151
+ case "initialize": {
152
+ const initialized = this.initialize(params);
153
+ return this.result(id, initialized, this.negotiatedVersion);
154
+ }
155
+ case "server/discover":
156
+ return this.result(
157
+ id,
158
+ {
159
+ supportedVersions: [...SUPPORTED_VERSIONS],
160
+ capabilities: { tools: { listChanged: false } },
161
+ _meta: {
162
+ "io.modelcontextprotocol/serverInfo": {
163
+ name: this.info.name,
164
+ version: this.info.version,
165
+ },
166
+ },
167
+ ...(this.info.instructions
168
+ ? { instructions: this.info.instructions }
169
+ : {}),
170
+ ttlMs: 0,
171
+ cacheScope: "private",
172
+ },
173
+ version,
174
+ );
175
+ case "ping":
176
+ return this.result(id, {}, version);
177
+ case "tools/list":
178
+ return this.result(
179
+ id,
180
+ {
181
+ tools: [...this.tools.values()].map(
182
+ ({ call: _call, outputSchema, ...tool }) => ({
183
+ ...tool,
184
+ ...(supportsStructuredResults(version) && outputSchema
185
+ ? { outputSchema }
186
+ : {}),
187
+ }),
188
+ ),
189
+ ...(version === "2026-07-28"
190
+ ? { ttlMs: 0, cacheScope: "private" }
191
+ : {}),
192
+ },
193
+ version,
194
+ );
195
+ case "tools/call":
196
+ return await this.callTool(id, params, version);
197
+ default:
198
+ return this.error(
199
+ id,
200
+ METHOD_NOT_FOUND,
201
+ `Method not found: ${method}`,
202
+ );
203
+ }
204
+ } catch (error) {
205
+ return this.error(
206
+ id,
207
+ INTERNAL_ERROR,
208
+ error instanceof Error ? error.message : String(error),
209
+ );
210
+ }
211
+ }
212
+
213
+ private initialize(params: Record<string, unknown>): Record<string, unknown> {
214
+ const requested = params.protocolVersion;
215
+ // Echo a supported initialize-era version, else offer our latest one.
216
+ const protocolVersion =
217
+ typeof requested === "string" &&
218
+ requested !== "2026-07-28" &&
219
+ (SUPPORTED_VERSIONS as readonly string[]).includes(requested)
220
+ ? requested
221
+ : LATEST_INITIALIZE_VERSION;
222
+ this.negotiatedVersion = protocolVersion;
223
+ return {
224
+ protocolVersion,
225
+ capabilities: { tools: { listChanged: false } },
226
+ serverInfo: { name: this.info.name, version: this.info.version },
227
+ ...(this.info.instructions
228
+ ? { instructions: this.info.instructions }
229
+ : {}),
230
+ };
231
+ }
232
+
233
+ private notify(method: string, params: Record<string, unknown>): void {
234
+ if (method === "notifications/cancelled" && isId(params.requestId)) {
235
+ this.inFlight.get(params.requestId)?.abort();
236
+ this.inFlight.delete(params.requestId);
237
+ }
238
+ // notifications/initialized and unknown notifications need no action.
239
+ }
240
+
241
+ private async callTool(
242
+ id: Id,
243
+ params: Record<string, unknown>,
244
+ version: string,
245
+ ): Promise<JsonRpcResponse | undefined> {
246
+ const tool =
247
+ typeof params.name === "string" ? this.tools.get(params.name) : undefined;
248
+ if (!tool)
249
+ return this.error(
250
+ id,
251
+ INVALID_PARAMS,
252
+ `Unknown tool: ${String(params.name)}`,
253
+ );
254
+ if (params.arguments !== undefined && !isRecord(params.arguments))
255
+ return this.error(
256
+ id,
257
+ INVALID_PARAMS,
258
+ "Tool arguments must be an object.",
259
+ );
260
+ const controller = new AbortController();
261
+ this.inFlight.set(id, controller);
262
+ // A cancelled request gets no response on any path: 2025-11-25 says
263
+ // receivers SHOULD NOT respond, and the 2026-07-28 stdio transport says
264
+ // servers MUST NOT send further messages for it. The signal stays aborted
265
+ // after the notification removes the entry, so the check is race-free.
266
+ const cancelled = () => controller.signal.aborted;
267
+ try {
268
+ const result = await tool.call(params.arguments ?? {}, controller.signal);
269
+ if (cancelled()) return undefined;
270
+ const { structuredContent, ...content } = result;
271
+ return this.result(
272
+ id,
273
+ {
274
+ ...content,
275
+ ...(supportsStructuredResults(version) && structuredContent
276
+ ? { structuredContent }
277
+ : {}),
278
+ },
279
+ version,
280
+ );
281
+ } catch (error) {
282
+ if (cancelled()) return undefined;
283
+ if (error instanceof McpInvalidParams)
284
+ return this.error(id, INVALID_PARAMS, error.message);
285
+ // Expected tool refusals already carry a report. An unexpected server
286
+ // fault has no trustworthy report/accounting; do not fabricate one.
287
+ return this.error(
288
+ id,
289
+ INTERNAL_ERROR,
290
+ `${tool.name} failed: ${error instanceof Error ? error.message : String(error)}`,
291
+ );
292
+ } finally {
293
+ this.inFlight.delete(id);
294
+ }
295
+ }
296
+
297
+ private result(
298
+ id: Id,
299
+ result: Record<string, unknown>,
300
+ version: string,
301
+ ): JsonRpcResponse {
302
+ return {
303
+ jsonrpc: "2.0",
304
+ id,
305
+ result: {
306
+ ...(version === "2026-07-28" ? { resultType: "complete" } : {}),
307
+ ...result,
308
+ },
309
+ };
310
+ }
311
+
312
+ error(
313
+ id: Id | null,
314
+ code: number,
315
+ message: string,
316
+ data?: unknown,
317
+ ): JsonRpcResponse {
318
+ return {
319
+ jsonrpc: "2.0",
320
+ id,
321
+ error: { code, message, ...(data === undefined ? {} : { data }) },
322
+ };
323
+ }
324
+ }
325
+
326
+ function supportsStructuredResults(version: string): boolean {
327
+ return (
328
+ version === "2025-06-18" ||
329
+ version === "2025-11-25" ||
330
+ version === "2026-07-28"
331
+ );
332
+ }